Feature: implement document link custom field
This commit is contained in:
parent
66efaedcbb
commit
6c4b56ae98
@ -343,6 +343,7 @@ The following custom field types are supported:
|
|||||||
- `Integer`: integer number e.g. 12
|
- `Integer`: integer number e.g. 12
|
||||||
- `Number`: float number e.g. 12.3456
|
- `Number`: float number e.g. 12.3456
|
||||||
- `Monetary`: float number with exactly two decimals, e.g. 12.30
|
- `Monetary`: float number with exactly two decimals, e.g. 12.30
|
||||||
|
- `Document Link`: reference(s) to other document(s), displayed as links
|
||||||
|
|
||||||
## Share Links
|
## Share Links
|
||||||
|
|
||||||
|
@ -106,6 +106,7 @@ import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/
|
|||||||
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
|
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
|
||||||
|
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
|
||||||
|
|
||||||
import localeAf from '@angular/common/locales/af'
|
import localeAf from '@angular/common/locales/af'
|
||||||
import localeAr from '@angular/common/locales/ar'
|
import localeAr from '@angular/common/locales/ar'
|
||||||
@ -259,6 +260,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
CustomFieldsDropdownComponent,
|
CustomFieldsDropdownComponent,
|
||||||
ProfileEditDialogComponent,
|
ProfileEditDialogComponent,
|
||||||
PdfViewerComponent,
|
PdfViewerComponent,
|
||||||
|
DocumentLinkComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
|
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
|
||||||
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
|
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
|
||||||
<small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
|
<small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
|
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||||
|
<div class="row">
|
||||||
|
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||||
|
<label *ngIf="title" class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||||
|
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||||
|
<svg class="sidebaricon" fill="currentColor">
|
||||||
|
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||||
|
</svg> <ng-container i18n>Remove</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div [class.col-md-9]="horizontal">
|
||||||
|
<div>
|
||||||
|
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[items]="foundDocuments$ | async"
|
||||||
|
placeholder="Search for documents"
|
||||||
|
[notFoundText]="notFoundText"
|
||||||
|
[multiple]="true"
|
||||||
|
bindLabel="title"
|
||||||
|
bindValue="id"
|
||||||
|
[compareWith]="compareDocuments"
|
||||||
|
[trackByFn]="trackByFn"
|
||||||
|
[minTermLength]="2"
|
||||||
|
[loading]="loading"
|
||||||
|
[typeahead]="documentsInput$"
|
||||||
|
(change)="onChange(selectedDocuments)">
|
||||||
|
<ng-template ng-label-tmp let-document="item">
|
||||||
|
<svg class="sidebaricon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" (click)="unselect(document)">
|
||||||
|
<use xlink:href="assets/bootstrap-icons.svg#x"/>
|
||||||
|
</svg>
|
||||||
|
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary">
|
||||||
|
<svg class="sidebaricon-sm me-1" fill="currentColor">
|
||||||
|
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
|
||||||
|
</svg><span>{{document.title}}</span>
|
||||||
|
</a>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template ng-loadingspinner-tmp>
|
||||||
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
|
</ng-template>
|
||||||
|
</ng-select>
|
||||||
|
</div>
|
||||||
|
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,14 @@
|
|||||||
|
::ng-deep .ng-select-container .ng-value-container .ng-value {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebaricon {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: .75rem;
|
||||||
|
// --bs-primary: var(--pngx-bg-alt);
|
||||||
|
// color: var(--pngx-primary-text-contrast);
|
||||||
|
}
|
@ -0,0 +1,98 @@
|
|||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { DocumentLinkComponent } from './document-link.component'
|
||||||
|
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
|
|
||||||
|
const documents = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Document 1 foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
title: 'Document 12 bar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 23,
|
||||||
|
title: 'Document 23 bar',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('DocumentLinkComponent', () => {
|
||||||
|
let component: DocumentLinkComponent
|
||||||
|
let fixture: ComponentFixture<DocumentLinkComponent>
|
||||||
|
let documentService: DocumentService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [DocumentLinkComponent],
|
||||||
|
imports: [
|
||||||
|
HttpClientTestingModule,
|
||||||
|
NgSelectModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
|
fixture = TestBed.createComponent(DocumentLinkComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retrieve selected documents from APIs', () => {
|
||||||
|
const getSpy = jest.spyOn(documentService, 'getCachedMany')
|
||||||
|
getSpy.mockImplementation((ids) => {
|
||||||
|
return of(documents.filter((d) => ids.includes(d.id)))
|
||||||
|
})
|
||||||
|
component.writeValue([1])
|
||||||
|
expect(getSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search API on select text input', () => {
|
||||||
|
const listSpy = jest.spyOn(documentService, 'listFiltered')
|
||||||
|
listSpy.mockImplementation(
|
||||||
|
(page, pageSize, sortField, sortReverse, filterRules, extraParams) => {
|
||||||
|
const docs = documents.filter((d) =>
|
||||||
|
d.title.includes(filterRules[0].value)
|
||||||
|
)
|
||||||
|
return of({
|
||||||
|
count: docs.length,
|
||||||
|
results: docs,
|
||||||
|
all: docs.map((d) => d.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
component.documentsInput$.next('bar')
|
||||||
|
expect(listSpy).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'created',
|
||||||
|
true,
|
||||||
|
[{ rule_type: FILTER_TITLE, value: 'bar' }],
|
||||||
|
{ truncate_content: true }
|
||||||
|
)
|
||||||
|
listSpy.mockReturnValueOnce(throwError(() => new Error()))
|
||||||
|
component.documentsInput$.next('foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support unselect', () => {
|
||||||
|
const getSpy = jest.spyOn(documentService, 'getCachedMany')
|
||||||
|
getSpy.mockImplementation((ids) => {
|
||||||
|
return of(documents.filter((d) => ids.includes(d.id)))
|
||||||
|
})
|
||||||
|
component.writeValue([12, 23])
|
||||||
|
component.unselect({ id: 23 })
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.selectedDocuments).toEqual([documents[1]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use correct compare, trackBy functions', () => {
|
||||||
|
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
|
||||||
|
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
|
||||||
|
expect(component.trackByFn(documents[1])).toEqual(12)
|
||||||
|
})
|
||||||
|
})
|
@ -0,0 +1,115 @@
|
|||||||
|
import { Component, forwardRef, OnInit, Input, OnDestroy } from '@angular/core'
|
||||||
|
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
Subject,
|
||||||
|
Observable,
|
||||||
|
takeUntil,
|
||||||
|
concat,
|
||||||
|
of,
|
||||||
|
distinctUntilChanged,
|
||||||
|
tap,
|
||||||
|
switchMap,
|
||||||
|
map,
|
||||||
|
catchError,
|
||||||
|
} from 'rxjs'
|
||||||
|
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
|
import { PaperlessDocument } from 'src/app/data/paperless-document'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => DocumentLinkComponent),
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selector: 'pngx-input-document-link',
|
||||||
|
templateUrl: './document-link.component.html',
|
||||||
|
styleUrls: ['./document-link.component.scss'],
|
||||||
|
})
|
||||||
|
export class DocumentLinkComponent
|
||||||
|
extends AbstractInputComponent<any[]>
|
||||||
|
implements OnInit, OnDestroy
|
||||||
|
{
|
||||||
|
documentsInput$ = new Subject<string>()
|
||||||
|
foundDocuments$: Observable<PaperlessDocument[]>
|
||||||
|
loading = false
|
||||||
|
selectedDocuments: PaperlessDocument[] = []
|
||||||
|
|
||||||
|
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
notFoundText: string = $localize`No documents found`
|
||||||
|
|
||||||
|
constructor(private documentsService: DocumentService) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadDocs()
|
||||||
|
}
|
||||||
|
|
||||||
|
writeValue(documentIDs: number[]): void {
|
||||||
|
this.loading = true
|
||||||
|
this.documentsService
|
||||||
|
.getCachedMany(documentIDs)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((documents) => {
|
||||||
|
this.loading = false
|
||||||
|
this.selectedDocuments = documents
|
||||||
|
super.writeValue(documentIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadDocs() {
|
||||||
|
this.foundDocuments$ = concat(
|
||||||
|
of([]), // default items
|
||||||
|
this.documentsInput$.pipe(
|
||||||
|
distinctUntilChanged(),
|
||||||
|
takeUntil(this.unsubscribeNotifier),
|
||||||
|
tap(() => (this.loading = true)),
|
||||||
|
switchMap((title) =>
|
||||||
|
this.documentsService
|
||||||
|
.listFiltered(
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
'created',
|
||||||
|
true,
|
||||||
|
[{ rule_type: FILTER_TITLE, value: title }],
|
||||||
|
{ truncate_content: true }
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
map((results) => results.results),
|
||||||
|
catchError(() => of([])), // empty on error
|
||||||
|
tap(() => (this.loading = false))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
unselect(document: PaperlessDocument): void {
|
||||||
|
this.selectedDocuments = this.selectedDocuments.filter(
|
||||||
|
(d) => d.id !== document.id
|
||||||
|
)
|
||||||
|
this.onChange(this.selectedDocuments.map((d) => d.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
compareDocuments(
|
||||||
|
document: PaperlessDocument,
|
||||||
|
selectedDocument: PaperlessDocument
|
||||||
|
) {
|
||||||
|
return document.id === selectedDocument.id
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByFn(item: PaperlessDocument) {
|
||||||
|
return item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.unsubscribeNotifier.next(true)
|
||||||
|
this.unsubscribeNotifier.complete()
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="suggestions">
|
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||||
<label class="form-label" [class.mb-md-0]="horizontal" for="tags" i18n>{{title}}</label>
|
<label class="form-label" [class.mb-md-0]="horizontal" for="tags" i18n>{{title}}</label>
|
||||||
|
@ -131,6 +131,7 @@
|
|||||||
<pngx-input-number *ngSwitchCase="PaperlessCustomFieldDataType.Monetary" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
|
<pngx-input-number *ngSwitchCase="PaperlessCustomFieldDataType.Monetary" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
|
||||||
<pngx-input-check *ngSwitchCase="PaperlessCustomFieldDataType.Boolean" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
|
<pngx-input-check *ngSwitchCase="PaperlessCustomFieldDataType.Boolean" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
|
||||||
<pngx-input-url *ngSwitchCase="PaperlessCustomFieldDataType.Url" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
|
<pngx-input-url *ngSwitchCase="PaperlessCustomFieldDataType.Url" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
|
||||||
|
<pngx-input-document-link *ngSwitchCase="PaperlessCustomFieldDataType.DocumentLink" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-document-link>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,6 +8,7 @@ export enum PaperlessCustomFieldDataType {
|
|||||||
Integer = 'integer',
|
Integer = 'integer',
|
||||||
Float = 'float',
|
Float = 'float',
|
||||||
Monetary = 'monetary',
|
Monetary = 'monetary',
|
||||||
|
DocumentLink = 'documentlink',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DATA_TYPE_LABELS = [
|
export const DATA_TYPE_LABELS = [
|
||||||
@ -39,6 +40,10 @@ export const DATA_TYPE_LABELS = [
|
|||||||
id: PaperlessCustomFieldDataType.Url,
|
id: PaperlessCustomFieldDataType.Url,
|
||||||
name: $localize`Url`,
|
name: $localize`Url`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: PaperlessCustomFieldDataType.DocumentLink,
|
||||||
|
name: $localize`Document Link`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface PaperlessCustomField extends ObjectWithId {
|
export interface PaperlessCustomField extends ObjectWithId {
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 4.2.7 on 2023-11-30 17:44
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("documents", "1041_alter_consumptiontemplate_sources"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="consumptiontemplate",
|
|
||||||
name="assign_custom_fields",
|
|
||||||
field=models.ManyToManyField(
|
|
||||||
blank=True,
|
|
||||||
related_name="+",
|
|
||||||
to="documents.customfield",
|
|
||||||
verbose_name="assign these custom fields",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2023-12-04 04:03
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "1041_alter_consumptiontemplate_sources"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="consumptiontemplate",
|
||||||
|
name="assign_custom_fields",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="+",
|
||||||
|
to="documents.customfield",
|
||||||
|
verbose_name="assign these custom fields",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customfieldinstance",
|
||||||
|
name="value_document_ids",
|
||||||
|
field=models.CharField(
|
||||||
|
max_length=128,
|
||||||
|
null=True,
|
||||||
|
validators=[django.core.validators.int_list_validator],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="customfield",
|
||||||
|
name="data_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("string", "String"),
|
||||||
|
("url", "URL"),
|
||||||
|
("date", "Date"),
|
||||||
|
("boolean", "Boolean"),
|
||||||
|
("integer", "Integer"),
|
||||||
|
("float", "Float"),
|
||||||
|
("monetary", "Monetary"),
|
||||||
|
("documentlink", "Document Link"),
|
||||||
|
],
|
||||||
|
editable=False,
|
||||||
|
max_length=50,
|
||||||
|
verbose_name="data type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -15,6 +15,7 @@ from django.contrib.auth.models import Group
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.validators import MaxValueValidator
|
from django.core.validators import MaxValueValidator
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
|
from django.core.validators import int_list_validator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -756,6 +757,7 @@ class CustomField(models.Model):
|
|||||||
INT = ("integer", _("Integer"))
|
INT = ("integer", _("Integer"))
|
||||||
FLOAT = ("float", _("Float"))
|
FLOAT = ("float", _("Float"))
|
||||||
MONETARY = ("monetary", _("Monetary"))
|
MONETARY = ("monetary", _("Monetary"))
|
||||||
|
DOCUMENTLINK = ("documentlink", _("Document Link"))
|
||||||
|
|
||||||
created = models.DateTimeField(
|
created = models.DateTimeField(
|
||||||
_("created"),
|
_("created"),
|
||||||
@ -834,6 +836,12 @@ class CustomFieldInstance(models.Model):
|
|||||||
|
|
||||||
value_monetary = models.DecimalField(null=True, decimal_places=2, max_digits=12)
|
value_monetary = models.DecimalField(null=True, decimal_places=2, max_digits=12)
|
||||||
|
|
||||||
|
value_document_ids = models.CharField(
|
||||||
|
validators=[int_list_validator],
|
||||||
|
max_length=128,
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("created",)
|
ordering = ("created",)
|
||||||
verbose_name = _("custom field instance")
|
verbose_name = _("custom field instance")
|
||||||
@ -868,6 +876,8 @@ class CustomFieldInstance(models.Model):
|
|||||||
return self.value_float
|
return self.value_float
|
||||||
elif self.field.data_type == CustomField.FieldDataType.MONETARY:
|
elif self.field.data_type == CustomField.FieldDataType.MONETARY:
|
||||||
return self.value_monetary
|
return self.value_monetary
|
||||||
|
elif self.field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
|
return self.value_document_ids
|
||||||
raise NotImplementedError(self.field.data_type)
|
raise NotImplementedError(self.field.data_type)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
import zoneinfo
|
import zoneinfo
|
||||||
@ -440,6 +441,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
|||||||
CustomField.FieldDataType.INT: "value_int",
|
CustomField.FieldDataType.INT: "value_int",
|
||||||
CustomField.FieldDataType.FLOAT: "value_float",
|
CustomField.FieldDataType.FLOAT: "value_float",
|
||||||
CustomField.FieldDataType.MONETARY: "value_monetary",
|
CustomField.FieldDataType.MONETARY: "value_monetary",
|
||||||
|
CustomField.FieldDataType.DOCUMENTLINK: "value_document_ids",
|
||||||
}
|
}
|
||||||
# An instance is attached to a document
|
# An instance is attached to a document
|
||||||
document: Document = validated_data["document"]
|
document: Document = validated_data["document"]
|
||||||
@ -458,7 +460,11 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
def get_value(self, obj: CustomFieldInstance):
|
def get_value(self, obj: CustomFieldInstance):
|
||||||
return obj.value
|
return (
|
||||||
|
obj.value
|
||||||
|
if (obj.field.data_type != CustomField.FieldDataType.DOCUMENTLINK)
|
||||||
|
else json.loads(obj.value)
|
||||||
|
)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
"""
|
"""
|
||||||
|
@ -34,7 +34,9 @@ class TestCustomField(DirectoriesMixin, APITestCase):
|
|||||||
("date", "Invoiced Date"),
|
("date", "Invoiced Date"),
|
||||||
("integer", "Invoice #"),
|
("integer", "Invoice #"),
|
||||||
("boolean", "Is Active"),
|
("boolean", "Is Active"),
|
||||||
("float", "Total Paid"),
|
("float", "Average Value"),
|
||||||
|
("monetary", "Total Paid"),
|
||||||
|
("documentlink", "Related Documents"),
|
||||||
]:
|
]:
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
self.ENDPOINT,
|
self.ENDPOINT,
|
||||||
@ -96,6 +98,10 @@ class TestCustomField(DirectoriesMixin, APITestCase):
|
|||||||
name="Test Custom Field Monetary",
|
name="Test Custom Field Monetary",
|
||||||
data_type=CustomField.FieldDataType.MONETARY,
|
data_type=CustomField.FieldDataType.MONETARY,
|
||||||
)
|
)
|
||||||
|
custom_field_documentlink = CustomField.objects.create(
|
||||||
|
name="Test Custom Field Doc Link",
|
||||||
|
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
|
)
|
||||||
|
|
||||||
date_value = date.today()
|
date_value = date.today()
|
||||||
|
|
||||||
@ -131,6 +137,10 @@ class TestCustomField(DirectoriesMixin, APITestCase):
|
|||||||
"field": custom_field_monetary.id,
|
"field": custom_field_monetary.id,
|
||||||
"value": 11.10,
|
"value": 11.10,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"field": custom_field_documentlink.id,
|
||||||
|
"value": [1, 2, 3],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
@ -150,11 +160,12 @@ class TestCustomField(DirectoriesMixin, APITestCase):
|
|||||||
{"field": custom_field_url.id, "value": "https://example.com"},
|
{"field": custom_field_url.id, "value": "https://example.com"},
|
||||||
{"field": custom_field_float.id, "value": 12.3456},
|
{"field": custom_field_float.id, "value": 12.3456},
|
||||||
{"field": custom_field_monetary.id, "value": 11.10},
|
{"field": custom_field_monetary.id, "value": 11.10},
|
||||||
|
{"field": custom_field_documentlink.id, "value": [1, 2, 3]},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
doc.refresh_from_db()
|
doc.refresh_from_db()
|
||||||
self.assertEqual(len(doc.custom_fields.all()), 7)
|
self.assertEqual(len(doc.custom_fields.all()), 8)
|
||||||
|
|
||||||
def test_change_custom_field_instance_value(self):
|
def test_change_custom_field_instance_value(self):
|
||||||
"""
|
"""
|
||||||
|
Loading…
x
Reference in New Issue
Block a user