mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-30 03:25:43 +00:00
Enhancement: relative date custom field queries (#14549)
This commit is contained in:
1 parent
c21bdab5c8
commit
67c194978e
7 files changed
+353
-19
No files matched your search
+11
@@ -162,6 +162,17 @@ including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
|
|||||||
Lastly, document link fields support a `contains` operator that behaves
|
Lastly, document link fields support a `contains` operator that behaves
|
||||||
like a "is superset of" check.
|
like a "is superset of" check.
|
||||||
|
|
||||||
|
Date fields also accept relative expressions supported by
|
||||||
|
[dateparser](https://dateparser.readthedocs.io/en/stable/), such as `today`,
|
||||||
|
`in 30 days`, or `1 month ago`. For example, dates within the next 30 days:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["expiry", "range", ["today", "in 30 days"]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Expressions resolve each time the query runs, using `PAPERLESS_TIME_ZONE` and
|
||||||
|
`PAPERLESS_DATE_PARSER_LANGUAGES` (automatic language detection when unset).
|
||||||
|
|
||||||
### `/api/search/autocomplete/`
|
### `/api/search/autocomplete/`
|
||||||
|
|
||||||
Get auto completions for a partial search term.
|
Get auto completions for a partial search term.
|
||||||
|
|||||||
+52
-18
@@ -33,21 +33,54 @@
|
|||||||
|
|
||||||
<ng-template #comparisonValueTemplate let-atom="atom">
|
<ng-template #comparisonValueTemplate let-atom="atom">
|
||||||
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
||||||
<input class="form-control" placeholder="yyyy-mm-dd"
|
@let relativeDate = getRelativeDate(atom);
|
||||||
[(ngModel)]="atom.value"
|
<div class="d-flex gap-2 w-100 ms-0 mt-1">
|
||||||
[ngModelOptions]="{standalone: true}"
|
<fieldset class="btn-group btn-group-sm flex-shrink-0 date-mode" aria-label="Date mode" i18n-aria-label>
|
||||||
ngbDatepicker
|
<input type="radio" class="btn-check" [id]="atom.id + '_fixed'" [name]="atom.id + '_dateMode'" [checked]="!relativeDate" (change)="toggleRelativeDate(atom)" [disabled]="disabled" aria-label="Fixed date" i18n-aria-label>
|
||||||
#d="ngbDatepicker"
|
<label class="btn btn-outline-secondary text-nowrap" [for]="atom.id + '_fixed'" title="Fixed date" i18n-title>
|
||||||
[footerTemplate]="datePickerFooterTemplate" />
|
<i-bs name="calendar"></i-bs><span class="d-none d-md-inline ms-2" i18n>Fixed</span>
|
||||||
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
|
</label>
|
||||||
<i-bs name="calendar-event"></i-bs>
|
<input type="radio" class="btn-check" [id]="atom.id + '_relative'" [name]="atom.id + '_dateMode'" [checked]="!!relativeDate" (change)="toggleRelativeDate(atom)" [disabled]="disabled" aria-label="Relative date" i18n-aria-label>
|
||||||
</button>
|
<label class="btn btn-outline-secondary text-nowrap" [for]="atom.id + '_relative'" title="Relative date" i18n-title>
|
||||||
<ng-template #datePickerFooterTemplate>
|
<i-bs name="clock-history"></i-bs><span class="d-none d-md-inline ms-2" i18n>Relative</span>
|
||||||
<div class="btn-group-xs border-top p-2 d-flex">
|
</label>
|
||||||
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
|
</fieldset>
|
||||||
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
|
<div class="input-group input-group-sm flex-grow-1">
|
||||||
|
@if (relativeDate) {
|
||||||
|
@if (relativeDate.unit.includes('{n}')) {
|
||||||
|
<input class="form-control w-25 flex-grow-0" type="number" min="0" aria-label="Amount" i18n-aria-label
|
||||||
|
[ngModel]="relativeDate.amount"
|
||||||
|
(ngModelChange)="updateRelativeDate(atom, { amount: $event })"
|
||||||
|
[ngModelOptions]="{standalone: true}" />
|
||||||
|
}
|
||||||
|
<label class="visually-hidden" [for]="atom.id + '_unit'" i18n>Unit</label>
|
||||||
|
<select class="form-select" [id]="atom.id + '_unit'"
|
||||||
|
[ngModel]="relativeDate.unit"
|
||||||
|
(ngModelChange)="updateRelativeDate(atom, { unit: $event })"
|
||||||
|
[ngModelOptions]="{standalone: true}">
|
||||||
|
@for (unit of relativeDateUnits; track unit.id) {
|
||||||
|
<option [ngValue]="unit.id">{{unit.label}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<input class="form-control" placeholder="yyyy-mm-dd"
|
||||||
|
[(ngModel)]="atom.value"
|
||||||
|
[ngModelOptions]="{standalone: true}"
|
||||||
|
ngbDatepicker
|
||||||
|
#d="ngbDatepicker"
|
||||||
|
[footerTemplate]="datePickerFooterTemplate" />
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
|
||||||
|
<i-bs name="calendar-event"></i-bs>
|
||||||
|
</button>
|
||||||
|
<ng-template #datePickerFooterTemplate>
|
||||||
|
<div class="btn-group-xs border-top p-2 d-flex">
|
||||||
|
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
|
||||||
|
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</div>
|
||||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
|
||||||
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
|
||||||
@@ -83,6 +116,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #queryAtom let-atom="atom">
|
<ng-template #queryAtom let-atom="atom">
|
||||||
|
<label class="visually-hidden" [for]="atom.id + '_operator'" i18n>Operator</label>
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<ng-select #fieldSelects
|
<ng-select #fieldSelects
|
||||||
class="paperless-input-select"
|
class="paperless-input-select"
|
||||||
@@ -96,7 +130,7 @@
|
|||||||
[appendTo]="selectAppendTo"
|
[appendTo]="selectAppendTo"
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
<select class="w-25 form-select" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
<select class="w-25 form-select" [id]="atom.id + '_operator'" [class.rounded-end]="hasDateValueRow(atom)" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||||
@for (operator of getOperatorsForField(atom.field); track operator.label) {
|
@for (operator of getOperatorsForField(atom.field); track operator.label) {
|
||||||
<option [ngValue]="operator.value">{{operator.label}}</option>
|
<option [ngValue]="operator.value">{{operator.label}}</option>
|
||||||
}
|
}
|
||||||
@@ -151,10 +185,10 @@
|
|||||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
|
|
||||||
<i-bs name="x-circle"></i-bs>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-link btn-sm text-danger pe-0 align-self-center" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
|
||||||
|
<i-bs name="x-circle"></i-bs>
|
||||||
|
</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #queryExpression let-expression="expression">
|
<ng-template #queryExpression let-expression="expression">
|
||||||
|
|||||||
+15
@@ -5,6 +5,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
--ng-select-border-radius: var(--bs-border-radius);
|
||||||
|
--ng-select-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2));
|
||||||
|
}
|
||||||
|
|
||||||
:host ::ng-deep .ng-select-container {
|
:host ::ng-deep .ng-select-container {
|
||||||
border-top-right-radius: 0 !important;
|
border-top-right-radius: 0 !important;
|
||||||
border-bottom-right-radius: 0 !important;
|
border-bottom-right-radius: 0 !important;
|
||||||
@@ -41,3 +46,13 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.date-mode .btn {
|
||||||
|
--bs-btn-color: var(--bs-secondary-color);
|
||||||
|
--bs-btn-hover-bg: transparent;
|
||||||
|
--bs-btn-hover-color: var(--bs-body-color);
|
||||||
|
--bs-btn-hover-border-color: var(--bs-border-color);
|
||||||
|
--bs-btn-active-bg: transparent;
|
||||||
|
--bs-btn-active-color: var(--bs-primary);
|
||||||
|
--bs-btn-active-border-color: var(--bs-border-color);
|
||||||
|
}
|
||||||
+78
-1
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { LOCALE_ID } from '@angular/core'
|
import { LOCALE_ID } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDateAdapter, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { of } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
@@ -11,6 +11,7 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
|||||||
import {
|
import {
|
||||||
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
||||||
CustomFieldQueryLogicalOperator,
|
CustomFieldQueryLogicalOperator,
|
||||||
|
CustomFieldQueryOperator,
|
||||||
CustomFieldQueryOperatorGroups,
|
CustomFieldQueryOperatorGroups,
|
||||||
} from 'src/app/data/custom-field-query'
|
} from 'src/app/data/custom-field-query'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
CustomFieldQueryElement,
|
CustomFieldQueryElement,
|
||||||
CustomFieldQueryExpression,
|
CustomFieldQueryExpression,
|
||||||
} from 'src/app/utils/custom-field-query-element'
|
} from 'src/app/utils/custom-field-query-element'
|
||||||
|
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||||
import {
|
import {
|
||||||
CustomFieldQueriesModel,
|
CustomFieldQueriesModel,
|
||||||
CustomFieldsQueryDropdownComponent,
|
CustomFieldsQueryDropdownComponent,
|
||||||
@@ -48,6 +50,12 @@ const customFields = [
|
|||||||
data_type: CustomFieldDataType.Monetary,
|
data_type: CustomFieldDataType.Monetary,
|
||||||
extra_data: { default_currency: 'EUR' },
|
extra_data: { default_currency: 'EUR' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Test Date Field',
|
||||||
|
data_type: CustomFieldDataType.Date,
|
||||||
|
extra_data: {},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('CustomFieldsQueryDropdownComponent', () => {
|
describe('CustomFieldsQueryDropdownComponent', () => {
|
||||||
@@ -69,6 +77,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
{ provide: LOCALE_ID, useValue: 'de' },
|
{ provide: LOCALE_ID, useValue: 'de' },
|
||||||
|
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -237,6 +246,74 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
expect(component.selectionModel.queries.length).toBe(1)
|
expect(component.selectionModel.queries.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should build relative date values from amount and unit', async () => {
|
||||||
|
component.useDropdown = false
|
||||||
|
const atom = new CustomFieldQueryAtom([
|
||||||
|
4,
|
||||||
|
CustomFieldQueryOperator.GreaterThanOrEqual,
|
||||||
|
'2026-10-22',
|
||||||
|
])
|
||||||
|
component.selectionModel.addAtom(atom)
|
||||||
|
fixture.detectChanges()
|
||||||
|
await fixture.whenStable()
|
||||||
|
const query = (selector: string) =>
|
||||||
|
fixture.nativeElement.querySelector(selector)
|
||||||
|
expect(query('[ngbDatepicker]')).not.toBeNull()
|
||||||
|
expect(query('input[id$="_fixed"]').checked).toBe(true)
|
||||||
|
|
||||||
|
query('input[id$="_relative"]').click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(atom.value).toBeNull()
|
||||||
|
const amount: HTMLInputElement = query('input[aria-label="Amount"]')
|
||||||
|
const unit: HTMLSelectElement = query('select[id$="_unit"]')
|
||||||
|
amount.value = '30'
|
||||||
|
amount.dispatchEvent(new Event('input'))
|
||||||
|
expect(atom.serialize()).toEqual([4, 'gte', '30 days ago'])
|
||||||
|
unit.selectedIndex = 6
|
||||||
|
unit.dispatchEvent(new Event('change'))
|
||||||
|
expect(atom.serialize()).toEqual([4, 'gte', 'in 30 weeks'])
|
||||||
|
unit.selectedIndex = 0
|
||||||
|
unit.dispatchEvent(new Event('change'))
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(atom.serialize()).toEqual([4, 'gte', 'today'])
|
||||||
|
expect(query('input[aria-label="Amount"]')).toBeNull()
|
||||||
|
|
||||||
|
query('input[id$="_fixed"]').click()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(atom.value).toBeNull()
|
||||||
|
expect(query('[ngbDatepicker]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should restore a saved relative date value', async () => {
|
||||||
|
component.useDropdown = false
|
||||||
|
const atom = new CustomFieldQueryAtom([
|
||||||
|
4,
|
||||||
|
CustomFieldQueryOperator.Exact,
|
||||||
|
'in 2 months',
|
||||||
|
])
|
||||||
|
component.selectionModel.addAtom(atom)
|
||||||
|
fixture.detectChanges()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(component.getRelativeDate(atom)).toEqual({
|
||||||
|
amount: 2,
|
||||||
|
unit: 'in {n} months',
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('input[aria-label="Amount"]').value
|
||||||
|
).toBe('2')
|
||||||
|
|
||||||
|
const today = new CustomFieldQueryAtom([
|
||||||
|
4,
|
||||||
|
CustomFieldQueryOperator.Exact,
|
||||||
|
'today',
|
||||||
|
])
|
||||||
|
expect(component.getRelativeDate(today)).toEqual({
|
||||||
|
amount: null,
|
||||||
|
unit: 'today',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('CustomFieldQueriesModel', () => {
|
describe('CustomFieldQueriesModel', () => {
|
||||||
let model: CustomFieldQueriesModel
|
let model: CustomFieldQueriesModel
|
||||||
|
|
||||||
|
|||||||
+81
@@ -49,6 +49,26 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||||
|
|
||||||
|
interface RelativeDate {
|
||||||
|
// The expression sent to the API, with {n} standing in for the amount
|
||||||
|
amount: number | null
|
||||||
|
unit: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRelativeDate(
|
||||||
|
value: string,
|
||||||
|
unit: string
|
||||||
|
): RelativeDate | undefined {
|
||||||
|
const [prefix, suffix] = unit.split('{n}')
|
||||||
|
if (suffix === undefined) {
|
||||||
|
return value === unit ? { amount: null, unit } : undefined
|
||||||
|
}
|
||||||
|
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return undefined
|
||||||
|
const amount = value.slice(prefix.length, value.length - suffix.length)
|
||||||
|
if (!amount || !Number.isInteger(+amount) || +amount < 0) return undefined
|
||||||
|
return { amount: +amount, unit }
|
||||||
|
}
|
||||||
|
|
||||||
export class CustomFieldQueriesModel {
|
export class CustomFieldQueriesModel {
|
||||||
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
||||||
private rootSubscriptions: Subscription[] = []
|
private rootSubscriptions: Subscription[] = []
|
||||||
@@ -305,6 +325,67 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
|
|
||||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||||
|
|
||||||
|
public readonly relativeDateUnits = [
|
||||||
|
{ id: 'today', label: $localize`Today` },
|
||||||
|
{ id: '{n} days ago', label: $localize`days ago` },
|
||||||
|
{ id: '{n} weeks ago', label: $localize`weeks ago` },
|
||||||
|
{ id: '{n} months ago', label: $localize`months ago` },
|
||||||
|
{ id: '{n} years ago', label: $localize`years ago` },
|
||||||
|
{ id: 'in {n} days', label: $localize`days from now` },
|
||||||
|
{ id: 'in {n} weeks', label: $localize`weeks from now` },
|
||||||
|
{ id: 'in {n} months', label: $localize`months from now` },
|
||||||
|
{ id: 'in {n} years', label: $localize`years from now` },
|
||||||
|
]
|
||||||
|
|
||||||
|
private readonly relativeDates = new Map<string, RelativeDate>()
|
||||||
|
|
||||||
|
getRelativeDate(atom: CustomFieldQueryAtom): RelativeDate | undefined {
|
||||||
|
if (!this.relativeDates.has(atom.id) && typeof atom.value === 'string') {
|
||||||
|
for (const { id } of this.relativeDateUnits) {
|
||||||
|
const relativeDate = parseRelativeDate(atom.value, id)
|
||||||
|
if (relativeDate) {
|
||||||
|
this.relativeDates.set(atom.id, relativeDate)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.relativeDates.get(atom.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDateValueRow(atom: CustomFieldQueryAtom): boolean {
|
||||||
|
return (
|
||||||
|
this.getCustomFieldByID(atom.field)?.data_type ===
|
||||||
|
CustomFieldDataType.Date &&
|
||||||
|
atom.operator !== CustomFieldQueryOperator.Exists &&
|
||||||
|
atom.operator !== CustomFieldQueryOperator.IsNull
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleRelativeDate(atom: CustomFieldQueryAtom) {
|
||||||
|
if (this.getRelativeDate(atom)) {
|
||||||
|
this.relativeDates.delete(atom.id)
|
||||||
|
atom.value = null
|
||||||
|
} else {
|
||||||
|
this.relativeDates.set(atom.id, { amount: null, unit: '{n} days ago' })
|
||||||
|
this.updateRelativeDate(atom, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRelativeDate(
|
||||||
|
atom: CustomFieldQueryAtom,
|
||||||
|
change: Partial<RelativeDate>
|
||||||
|
) {
|
||||||
|
const { amount, unit } = Object.assign(
|
||||||
|
this.relativeDates.get(atom.id),
|
||||||
|
change
|
||||||
|
)
|
||||||
|
if (!unit.includes('{n}')) {
|
||||||
|
atom.value = unit
|
||||||
|
} else {
|
||||||
|
atom.value = amount === null ? null : unit.replace('{n}', `${amount}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
|
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
|
||||||
matchesSearchText(field?.name, term)
|
matchesSearchText(field?.name, term)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from decimal import InvalidOperation
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import dateparser
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db.models import Case
|
from django.db.models import Case
|
||||||
from django.db.models import CharField
|
from django.db.models import CharField
|
||||||
@@ -25,6 +27,7 @@ from django.db.models import Value
|
|||||||
from django.db.models import When
|
from django.db.models import When
|
||||||
from django.db.models.functions import Cast
|
from django.db.models.functions import Cast
|
||||||
from django.db.models.functions import NullIf
|
from django.db.models.functions import NullIf
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_filters import DateFilter
|
from django_filters import DateFilter
|
||||||
from django_filters.rest_framework import BooleanFilter
|
from django_filters.rest_framework import BooleanFilter
|
||||||
@@ -57,6 +60,7 @@ from documents.versioning import annotate_effective_content
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
|
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
|
||||||
ID_KWARGS = ["in", "exact"]
|
ID_KWARGS = ["in", "exact"]
|
||||||
@@ -322,6 +326,51 @@ class MonetaryAmountField(serializers.Field):
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class DateQueryField(serializers.DateField):
|
||||||
|
"""
|
||||||
|
Accepts either a fixed date or a relative date expression using `dateparser` and
|
||||||
|
resolves it against the provided reference datetime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
default_error_messages = {
|
||||||
|
"expression": _(
|
||||||
|
'Expected a date or a relative date such as "today" or "in 30 days".',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *, reference_datetime: datetime, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.reference_datetime = reference_datetime
|
||||||
|
|
||||||
|
def to_internal_value(self, value):
|
||||||
|
try:
|
||||||
|
# Let the parent class handle fixed-date formats and string validation first
|
||||||
|
return super().to_internal_value(value)
|
||||||
|
except serializers.ValidationError:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = dateparser.parse(
|
||||||
|
value,
|
||||||
|
# The UI always builds English expressions
|
||||||
|
locales=[*settings.DATE_PARSER_LANGUAGES, "en"]
|
||||||
|
if settings.DATE_PARSER_LANGUAGES
|
||||||
|
else None,
|
||||||
|
settings={
|
||||||
|
"PARSERS": ["relative-time"],
|
||||||
|
"RELATIVE_BASE": self.reference_datetime,
|
||||||
|
"TIMEZONE": str(self.reference_datetime.tzinfo),
|
||||||
|
"RETURN_AS_TIMEZONE_AWARE": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except (ValueError, OverflowError):
|
||||||
|
self.fail("expression")
|
||||||
|
if parsed is None:
|
||||||
|
self.fail("expression")
|
||||||
|
return parsed.astimezone(self.reference_datetime.tzinfo).date()
|
||||||
|
|
||||||
|
|
||||||
class SelectField(serializers.CharField):
|
class SelectField(serializers.CharField):
|
||||||
def __init__(self, custom_field: CustomField) -> None:
|
def __init__(self, custom_field: CustomField) -> None:
|
||||||
self._options = custom_field.extra_data["select_options"]
|
self._options = custom_field.extra_data["select_options"]
|
||||||
@@ -463,6 +512,8 @@ class CustomFieldQueryParser:
|
|||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
{self._validation_prefix: [_("Value must be valid JSON.")]},
|
{self._validation_prefix: [_("Value must be valid JSON.")]},
|
||||||
)
|
)
|
||||||
|
# Save the current local time as the reference for the query
|
||||||
|
self._reference_datetime = timezone.localtime()
|
||||||
return (
|
return (
|
||||||
self._parse_expr(expr, validation_prefix=self._validation_prefix),
|
self._parse_expr(expr, validation_prefix=self._validation_prefix),
|
||||||
self._annotations,
|
self._annotations,
|
||||||
@@ -649,6 +700,8 @@ class CustomFieldQueryParser:
|
|||||||
):
|
):
|
||||||
# DateField admits queries in the form of `year__exact`, etc. These take integers.
|
# DateField admits queries in the form of `year__exact`, etc. These take integers.
|
||||||
field = serializers.IntegerField()
|
field = serializers.IntegerField()
|
||||||
|
elif custom_field.data_type == CustomField.FieldDataType.DATE:
|
||||||
|
field = DateQueryField(reference_datetime=self._reference_datetime)
|
||||||
elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
elif custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
# We can be more specific here and make sure the value is a list.
|
# We can be more specific here and make sure the value is a list.
|
||||||
field = serializers.ListField(child=serializers.IntegerField())
|
field = serializers.ListField(child=serializers.IntegerField())
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import json
|
|||||||
import types
|
import types
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import time_machine
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
@@ -443,6 +447,65 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@override_settings(TIME_ZONE="America/Los_Angeles")
|
||||||
|
def test_relative_dates(self) -> None:
|
||||||
|
# Reevaluate the same mixed relative/fixed range on different local dates.
|
||||||
|
for today in (date(2024, 8, 22), date(2024, 11, 15)):
|
||||||
|
with (
|
||||||
|
self.subTest(today=today),
|
||||||
|
time_machine.travel(f"{today}T23:30:00-07:00", tick=False),
|
||||||
|
patch(
|
||||||
|
"documents.filters.timezone.localtime",
|
||||||
|
wraps=timezone.localtime,
|
||||||
|
) as localtime,
|
||||||
|
):
|
||||||
|
self._assert_query_match_predicate(
|
||||||
|
["date_field", "range", ["today", "2024-11-15"]],
|
||||||
|
lambda document, today=today: (
|
||||||
|
"date_field" in document
|
||||||
|
and document["date_field"] is not None
|
||||||
|
and today <= document["date_field"] <= date(2024, 11, 15)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
localtime.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_relative_date_validation(self) -> None:
|
||||||
|
for value, keyword in (
|
||||||
|
("not a date", "date"),
|
||||||
|
("January 2024", "date"),
|
||||||
|
("today + " + "9" * 5000 + " days", "date"),
|
||||||
|
(True, "format"),
|
||||||
|
):
|
||||||
|
with self.subTest(value=str(value)[:50]):
|
||||||
|
self._assert_validation_error(
|
||||||
|
json.dumps(["date_field", "exact", value]),
|
||||||
|
["custom_field_query", "2"],
|
||||||
|
keyword,
|
||||||
|
)
|
||||||
|
|
||||||
|
@time_machine.travel("2024-08-22T12:00:00Z", tick=False)
|
||||||
|
def test_relative_date_languages(self) -> None:
|
||||||
|
for locales in (["de"], ["zh-Hans", "de"], None):
|
||||||
|
with (
|
||||||
|
self.subTest(locales=locales),
|
||||||
|
override_settings(
|
||||||
|
DATE_PARSER_LANGUAGES=locales,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
self._assert_query_match_predicate(
|
||||||
|
["date_field", "exact", "heute"],
|
||||||
|
lambda document: (
|
||||||
|
"date_field" in document
|
||||||
|
and document["date_field"] == date(2024, 8, 22)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with override_settings(DATE_PARSER_LANGUAGES=["en"]):
|
||||||
|
self._assert_validation_error(
|
||||||
|
json.dumps(["date_field", "exact", "heute"]),
|
||||||
|
["custom_field_query", "2"],
|
||||||
|
"date",
|
||||||
|
)
|
||||||
|
|
||||||
def test_gt_monetary(self) -> None:
|
def test_gt_monetary(self) -> None:
|
||||||
self._assert_query_match_predicate(
|
self._assert_query_match_predicate(
|
||||||
["monetary_field", "gt", "99"],
|
["monetary_field", "gt", "99"],
|
||||||
|
|||||||
Reference in new issue
Block a user