mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-12-24 02:05:48 -06:00
Compare commits
19 Commits
feature-69
...
0f6b266106
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f6b266106 | ||
|
|
89c09283e5 | ||
|
|
c3bed3a4f9 | ||
|
|
6cdee1fca9 | ||
|
|
04a16cb723 | ||
|
|
5810487762 | ||
|
|
e99fef3c52 | ||
|
|
6048ac93b9 | ||
|
|
9883e9c68a | ||
|
|
3c3377799c | ||
|
|
39eef0b13a | ||
|
|
bf11dd2908 | ||
|
|
1efe5c4625 | ||
|
|
4d5c22471e | ||
|
|
abe9d7c04f | ||
|
|
66b3b6dd4a | ||
|
|
07abad3315 | ||
|
|
ef25dbc1bb | ||
|
|
59db0ea879 |
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
||||||
|
|
||||||
|
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
||||||
|
|
||||||
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
||||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (patternRequired) {
|
@if (patternRequired) {
|
||||||
|
|||||||
@@ -35,11 +35,16 @@ import { TextComponent } from '../../input/text/text.component'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||||
|
tags: Tag[]
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(TagService)
|
this.service = inject(TagService)
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
|
this.service.listAll().subscribe((result) => {
|
||||||
|
this.tags = result.results
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
@@ -55,6 +60,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
|||||||
name: new FormControl(''),
|
name: new FormControl(''),
|
||||||
color: new FormControl(randomColor()),
|
color: new FormControl(randomColor()),
|
||||||
is_inbox_tag: new FormControl(false),
|
is_inbox_tag: new FormControl(false),
|
||||||
|
parent: new FormControl(null),
|
||||||
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
|
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
|
||||||
match: new FormControl(''),
|
match: new FormControl(''),
|
||||||
is_insensitive: new FormControl(true),
|
is_insensitive: new FormControl(true),
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
||||||
) {
|
) {
|
||||||
return 1
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve hierarchical order when provided (e.g., Tags)
|
||||||
|
const ao = (a as any)['orderIndex']
|
||||||
|
const bo = (b as any)['orderIndex']
|
||||||
|
if (ao !== undefined && bo !== undefined) {
|
||||||
|
return ao - bo
|
||||||
} else if (
|
} else if (
|
||||||
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
||||||
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
||||||
|
|||||||
@@ -15,12 +15,17 @@
|
|||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1">
|
||||||
@if (isTag) {
|
@if (isTag && getDepth() > 0) {
|
||||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
<div class="indicator"></div>
|
||||||
} @else {
|
|
||||||
<small>{{item.name}}</small>
|
|
||||||
}
|
}
|
||||||
|
<div>
|
||||||
|
@if (isTag) {
|
||||||
|
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||||
|
} @else {
|
||||||
|
<small>{{item.name}}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (!hideCount) {
|
@if (!hideCount) {
|
||||||
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
||||||
|
|||||||
@@ -2,3 +2,19 @@
|
|||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
min-height: 1em;
|
min-height: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: .8rem;
|
||||||
|
height: .8rem;
|
||||||
|
border-left: 1px solid var(--bs-secondary);
|
||||||
|
border-bottom: 1px solid var(--bs-secondary);
|
||||||
|
margin-right: .25rem;
|
||||||
|
margin-left: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { TagComponent } from '../../tag/tag.component'
|
import { TagComponent } from '../../tag/tag.component'
|
||||||
|
|
||||||
export enum ToggleableItemState {
|
export enum ToggleableItemState {
|
||||||
@@ -18,7 +19,7 @@ export enum ToggleableItemState {
|
|||||||
})
|
})
|
||||||
export class ToggleableDropdownButtonComponent {
|
export class ToggleableDropdownButtonComponent {
|
||||||
@Input()
|
@Input()
|
||||||
item: MatchingModel
|
item: MatchingModel | Tag
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
state: ToggleableItemState
|
state: ToggleableItemState
|
||||||
@@ -45,6 +46,10 @@ export class ToggleableDropdownButtonComponent {
|
|||||||
return 'is_inbox_tag' in this.item
|
return 'is_inbox_tag' in this.item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDepth(): number {
|
||||||
|
return (this.item as Tag).depth ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
get currentCount(): number {
|
get currentCount(): number {
|
||||||
return this.count ?? this.item.document_count
|
return this.count ?? this.item.document_count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@
|
|||||||
<div class="input-group flex-nowrap">
|
<div class="input-group flex-nowrap">
|
||||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[multiple]="true"
|
[multiple]="multiple"
|
||||||
[closeOnSelect]="false"
|
[closeOnSelect]="false"
|
||||||
[clearSearchOnAdd]="true"
|
[clearSearchOnAdd]="true"
|
||||||
[hideSelected]="tags.length > 0"
|
[hideSelected]="tags.length > 0"
|
||||||
[addTag]="allowCreate ? createTagRef : false"
|
[addTag]="allowCreate ? createTagRef : false"
|
||||||
addTagText="Add tag"
|
addTagText="Add tag"
|
||||||
i18n-addTagText
|
i18n-addTagText
|
||||||
|
(add)="onAdd($event)"
|
||||||
(change)="onChange(value)">
|
(change)="onChange(value)">
|
||||||
|
|
||||||
<ng-template ng-label-tmp let-item="item">
|
<ng-template ng-label-tmp let-item="item">
|
||||||
@@ -25,9 +26,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
||||||
<div class="tag-wrap">
|
<div class="tag-option-row d-flex align-items-center">
|
||||||
@if (item.id && tags) {
|
@if (item.id && tags) {
|
||||||
<pngx-tag class="me-2" [tag]="getTag(item.id)"></pngx-tag>
|
@if (getTag(item.id)?.parent) {
|
||||||
|
<i-bs name="list-nested" class="me-1"></i-bs>
|
||||||
|
<span class="hierarchy-reveal d-flex align-items-center">
|
||||||
|
<span class="parents d-flex align-items-center">
|
||||||
|
@for (p of getParentChain(item.id); track p.id) {
|
||||||
|
<span class="badge me-1" [style.background]="p.color" [style.color]="p.text_color">{{p.name}}</span>
|
||||||
|
<i-bs name="chevron-right" width=".8em" height=".8em" class="me-1"></i-bs>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<pngx-tag class="current-tag d-flex" [tag]="getTag(item.id)"></pngx-tag>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -20,3 +20,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dropdown hierarchy reveal for ng-select options
|
||||||
|
::ng-deep .ng-dropdown-panel .ng-option {
|
||||||
|
.tag-option-row {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hierarchy-reveal {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 0;
|
||||||
|
transition: max-width 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .hierarchy-indicator {
|
||||||
|
// display: inline-block;
|
||||||
|
// width: .35rem;
|
||||||
|
// height: 1rem;
|
||||||
|
// border-radius: .25rem;
|
||||||
|
// background: var(--pngx-border-color, rgba(0,0,0,.2));
|
||||||
|
// }
|
||||||
|
|
||||||
|
.parents .badge {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand reveal area when hovering or when option is keyboard-marked
|
||||||
|
::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; // effectively "auto" for transition
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade indicator when expanded
|
||||||
|
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
|
||||||
|
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
@Input()
|
@Input()
|
||||||
horizontal: boolean = false
|
horizontal: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
multiple: boolean = true
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
filterDocuments = new EventEmitter<Tag[]>()
|
filterDocuments = new EventEmitter<Tag[]>()
|
||||||
|
|
||||||
@@ -124,13 +127,40 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
|
|
||||||
let index = this.value.indexOf(tagID)
|
let index = this.value.indexOf(tagID)
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
|
const tag = this.getTag(tagID)
|
||||||
|
|
||||||
|
// remove tag
|
||||||
let oldValue = this.value
|
let oldValue = this.value
|
||||||
oldValue.splice(index, 1)
|
oldValue.splice(index, 1)
|
||||||
|
|
||||||
|
// remove children
|
||||||
|
oldValue = this.removeChildren(oldValue, tag)
|
||||||
|
|
||||||
this.value = [...oldValue]
|
this.value = [...oldValue]
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removeChildren(tagIDs: number[], tag: Tag) {
|
||||||
|
if (tag.children?.length) {
|
||||||
|
const childIDs = tag.children.map((child) => child.id)
|
||||||
|
tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
|
||||||
|
for (const child of tag.children) {
|
||||||
|
tagIDs = this.removeChildren(tagIDs, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tagIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
public onAdd(tag: Tag) {
|
||||||
|
if (tag.parent) {
|
||||||
|
// add all parents recursively
|
||||||
|
const parent = this.getTag(tag.parent)
|
||||||
|
this.value = [...this.value, parent.id]
|
||||||
|
this.onAdd(parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createTag(name: string = null, add: boolean = false) {
|
createTag(name: string = null, add: boolean = false) {
|
||||||
var modal = this.modalService.open(TagEditDialogComponent, {
|
var modal = this.modalService.open(TagEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
@@ -166,6 +196,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
|
|
||||||
addTag(id) {
|
addTag(id) {
|
||||||
this.value = [...this.value, id]
|
this.value = [...this.value, id]
|
||||||
|
this.onAdd(this.getTag(id))
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,4 +211,22 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
this.tags.filter((t) => this.value.includes(t.id))
|
this.tags.filter((t) => this.value.includes(t.id))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns ancestors from root → immediate parent for a tag id
|
||||||
|
*/
|
||||||
|
getParentChain(id: number): Tag[] {
|
||||||
|
const chain: Tag[] = []
|
||||||
|
let current = this.getTag(id)
|
||||||
|
const guard = new Set<number>()
|
||||||
|
while (current?.parent) {
|
||||||
|
if (guard.has(current.parent)) break
|
||||||
|
guard.add(current.parent)
|
||||||
|
const parent = this.getTag(current.parent)
|
||||||
|
if (!parent) break
|
||||||
|
chain.unshift(parent)
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
@if (tag) {
|
@if (tag) {
|
||||||
|
@if (showParents && tag.parent) {
|
||||||
|
<pngx-tag [tagID]="tag.parent" [clickable]="clickable" [linkTitle]="linkTitle"></pngx-tag>
|
||||||
|
>
|
||||||
|
}
|
||||||
@if (!clickable) {
|
@if (!clickable) {
|
||||||
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
|
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,7 @@ export class TagComponent {
|
|||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
clickable: boolean = false
|
clickable: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
showParents: boolean = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1204,7 +1204,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(tagListAllSpy).toHaveBeenCalled()
|
expect(tagListAllSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
|
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
|
||||||
expect(component.tagSelectionModel.items).toEqual(
|
expect(component.tagSelectionModel.items).toMatchObject(
|
||||||
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
|
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
|||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
@@ -164,7 +165,10 @@ export class BulkEditorComponent
|
|||||||
this.tagService
|
this.tagService
|
||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((result) => (this.tagSelectionModel.items = result.results))
|
.subscribe(
|
||||||
|
(result) =>
|
||||||
|
(this.tagSelectionModel.items = flattenTags(result.results))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
this.permissionService.currentUserCan(
|
this.permissionService.currentUserCan(
|
||||||
@@ -648,7 +652,7 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(({ newTag, tags }) => {
|
.subscribe(({ newTag, tags }) => {
|
||||||
this.tagSelectionModel.items = tags.results
|
this.tagSelectionModel.items = flattenTags(tags.results)
|
||||||
this.tagSelectionModel.toggle(newTag.id)
|
this.tagSelectionModel.toggle(newTag.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||||
LogicalOperator.And
|
LogicalOperator.And
|
||||||
)
|
)
|
||||||
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
|
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
|
||||||
// coverage
|
// coverage
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
@@ -615,7 +615,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||||
LogicalOperator.Or
|
LogicalOperator.Or
|
||||||
)
|
)
|
||||||
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
|
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
|
||||||
// coverage
|
// coverage
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
@@ -652,7 +652,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||||
LogicalOperator.And
|
LogicalOperator.And
|
||||||
)
|
)
|
||||||
expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags)
|
expect(component.tagSelectionModel.getExcludedItems()).toMatchObject(tags)
|
||||||
// coverage
|
// coverage
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ import {
|
|||||||
CustomFieldQueryExpression,
|
CustomFieldQueryExpression,
|
||||||
} from 'src/app/utils/custom-field-query-element'
|
} from 'src/app/utils/custom-field-query-element'
|
||||||
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
|
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
|
||||||
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
import {
|
import {
|
||||||
CustomFieldQueriesModel,
|
CustomFieldQueriesModel,
|
||||||
CustomFieldsQueryDropdownComponent,
|
CustomFieldsQueryDropdownComponent,
|
||||||
@@ -1134,7 +1135,7 @@ export class FilterEditorComponent
|
|||||||
) {
|
) {
|
||||||
this.loadingCountTotal++
|
this.loadingCountTotal++
|
||||||
this.tagService.listAll().subscribe((result) => {
|
this.tagService.listAll().subscribe((result) => {
|
||||||
this.tagSelectionModel.items = result.results
|
this.tagSelectionModel.items = flattenTags(result.results)
|
||||||
this.maybeCompleteLoading()
|
this.maybeCompleteLoading()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +28,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
|||||||
@@ -54,61 +54,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (object of data; track object) {
|
@for (object of data; track object) {
|
||||||
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
|
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
|
||||||
<td>
|
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
|
|
||||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td scope="row"><button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button> </td>
|
|
||||||
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
|
||||||
<td scope="row">{{ object.document_count }}</td>
|
|
||||||
@for (column of extraColumns; track column) {
|
|
||||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
|
||||||
@if (column.rendersHtml) {
|
|
||||||
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
|
||||||
} @else if (column.monospace) {
|
|
||||||
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
|
|
||||||
} @else {
|
|
||||||
{{ column.valueFn.call(null, object) }}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
<td scope="row">
|
|
||||||
<div class="btn-toolbar gap-2">
|
|
||||||
<div class="btn-group d-block d-sm-none">
|
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
|
||||||
<i-bs name="three-dots-vertical"></i-bs>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
|
||||||
<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 (object.document_count > 0) {
|
|
||||||
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ object.document_count }})</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group d-none d-sm-inline-block">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
|
||||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
|
|
||||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@if (object.document_count > 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">{{ object.document_count }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -129,3 +75,72 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<ng-template #objectRow let-object="object" let-depth="depth">
|
||||||
|
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
|
||||||
|
<td>
|
||||||
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
|
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
|
||||||
|
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td scope="row" class="name-cell" style="--depth: {{depth}}">
|
||||||
|
@if (depth > 0) {
|
||||||
|
<div class="indicator"></div>
|
||||||
|
}
|
||||||
|
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||||
|
</td>
|
||||||
|
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||||
|
<td scope="row">{{ getDocumentCount(object) }}</td>
|
||||||
|
@for (column of extraColumns; track column) {
|
||||||
|
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||||
|
@if (column.rendersHtml) {
|
||||||
|
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
||||||
|
} @else if (column.monospace) {
|
||||||
|
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
|
||||||
|
} @else {
|
||||||
|
{{ column.valueFn.call(null, object) }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
<td scope="row">
|
||||||
|
<div class="btn-toolbar gap-2">
|
||||||
|
<div class="btn-group d-block d-sm-none">
|
||||||
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group d-none d-sm-inline-block">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||||
|
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
|
||||||
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@if (object.children && object.children.length > 0) {
|
||||||
|
@for (child of object.children; track child) {
|
||||||
|
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: child, depth: depth + 1 }"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|||||||
@@ -10,3 +10,17 @@ tbody tr:last-child td {
|
|||||||
.form-check {
|
.form-check {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
td.name-cell {
|
||||||
|
padding-left: calc(calc(var(--depth) - 1) * 1.1rem);
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
display: inline-block;
|
||||||
|
width: .8rem;
|
||||||
|
height: .8rem;
|
||||||
|
border-left: 1px solid var(--bs-secondary);
|
||||||
|
border-bottom: 1px solid var(--bs-secondary);
|
||||||
|
margin-right: .25rem;
|
||||||
|
margin-left: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
||||||
|
|
||||||
public data: T[] = []
|
public data: T[] = []
|
||||||
|
private unfilteredData: T[] = []
|
||||||
|
|
||||||
public page = 1
|
public page = 1
|
||||||
|
|
||||||
@@ -132,6 +133,18 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.reloadData()
|
this.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected filterData(data: T[]): T[] {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocumentCount(object: MatchingModel): number {
|
||||||
|
return (
|
||||||
|
object.document_count ??
|
||||||
|
this.unfilteredData.find((d) => d.id == object.id)?.document_count ??
|
||||||
|
0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
reloadData(extraParams: { [key: string]: any } = null) {
|
reloadData(extraParams: { [key: string]: any } = null) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
@@ -148,7 +161,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
.pipe(
|
.pipe(
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap((c) => {
|
tap((c) => {
|
||||||
this.data = c.results
|
this.unfilteredData = c.results
|
||||||
|
this.data = this.filterData(c.results)
|
||||||
this.collectionSize = c.count
|
this.collectionSize = c.count
|
||||||
}),
|
}),
|
||||||
delay(100)
|
delay(100)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
@@ -59,4 +60,8 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
getDeleteMessage(object: Tag) {
|
getDeleteMessage(object: Tag) {
|
||||||
return $localize`Do you really want to delete the tag "${object.name}"?`
|
return $localize`Do you really want to delete the tag "${object.name}"?`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filterData(data: Tag[]) {
|
||||||
|
return data.filter((tag) => !tag.parent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,12 @@ export interface Tag extends MatchingModel {
|
|||||||
text_color?: string
|
text_color?: string
|
||||||
|
|
||||||
is_inbox_tag?: boolean
|
is_inbox_tag?: boolean
|
||||||
|
|
||||||
|
parent?: number // Tag ID
|
||||||
|
|
||||||
|
children?: Tag[] // read-only
|
||||||
|
|
||||||
|
// UI-only: computed depth and order for hierarchical dropdowns
|
||||||
|
depth?: number
|
||||||
|
orderIndex?: number
|
||||||
}
|
}
|
||||||
|
|||||||
34
src-ui/src/app/utils/flatten-tags.ts
Normal file
34
src-ui/src/app/utils/flatten-tags.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Tag } from '../data/tag'
|
||||||
|
|
||||||
|
export function flattenTags(all: Tag[]): Tag[] {
|
||||||
|
const map = new Map<number, Tag>(
|
||||||
|
all.map((t) => [t.id, { ...t, children: [] }])
|
||||||
|
)
|
||||||
|
// rebuild children
|
||||||
|
for (const t of map.values()) {
|
||||||
|
if (t.parent) {
|
||||||
|
const p = map.get(t.parent)
|
||||||
|
p && p.children.push(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const roots = Array.from(map.values()).filter((t) => !t.parent)
|
||||||
|
const sortByName = (a: Tag, b: Tag) =>
|
||||||
|
(a.name || '').localeCompare(b.name || '', undefined, {
|
||||||
|
sensitivity: 'base',
|
||||||
|
numeric: true,
|
||||||
|
})
|
||||||
|
const ordered: Tag[] = []
|
||||||
|
let idx = 0
|
||||||
|
const walk = (node: Tag, depth: number) => {
|
||||||
|
node.depth = depth
|
||||||
|
node.orderIndex = idx++
|
||||||
|
ordered.push(node)
|
||||||
|
if (node.children?.length) {
|
||||||
|
for (const child of [...node.children].sort(sortByName)) {
|
||||||
|
walk(child, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roots.sort(sortByName).forEach((r) => walk(r, 0))
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
chevronDoubleRight,
|
chevronDoubleRight,
|
||||||
|
chevronRight,
|
||||||
clipboard,
|
clipboard,
|
||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
@@ -94,6 +95,7 @@ import {
|
|||||||
infoCircle,
|
infoCircle,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
|
listNested,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
microsoft,
|
microsoft,
|
||||||
@@ -264,6 +266,7 @@ const icons = {
|
|||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
chevronDoubleRight,
|
chevronDoubleRight,
|
||||||
|
chevronRight,
|
||||||
clipboard,
|
clipboard,
|
||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
@@ -303,6 +306,7 @@ const icons = {
|
|||||||
infoCircle,
|
infoCircle,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
|
listNested,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
microsoft,
|
microsoft,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from documents.models import CustomFieldInstance
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
|
from documents.models import Tag
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
@@ -96,31 +97,46 @@ def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Litera
|
|||||||
|
|
||||||
|
|
||||||
def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(Q(id__in=doc_ids) & ~Q(tags__id=tag)).only("pk")
|
tag_obj = Tag.objects.get(pk=tag)
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
tags_to_add = [tag_obj, *tag_obj.get_all_ancestors()]
|
||||||
|
|
||||||
DocumentTagRelationship = Document.tags.through
|
DocumentTagRelationship = Document.tags.through
|
||||||
|
to_create = []
|
||||||
|
affected_docs: set[int] = set()
|
||||||
|
|
||||||
DocumentTagRelationship.objects.bulk_create(
|
for t in tags_to_add:
|
||||||
[DocumentTagRelationship(document_id=doc, tag_id=tag) for doc in affected_docs],
|
qs = Document.objects.filter(Q(id__in=doc_ids) & ~Q(tags__id=t.id)).only("pk")
|
||||||
)
|
doc_ids_missing_tag = list(qs.values_list("pk", flat=True))
|
||||||
|
affected_docs.update(doc_ids_missing_tag)
|
||||||
|
to_create.extend(
|
||||||
|
DocumentTagRelationship(document_id=doc, tag_id=t.id)
|
||||||
|
for doc in doc_ids_missing_tag
|
||||||
|
)
|
||||||
|
|
||||||
bulk_update_documents.delay(document_ids=affected_docs)
|
if to_create:
|
||||||
|
DocumentTagRelationship.objects.bulk_create(to_create)
|
||||||
|
|
||||||
|
if affected_docs:
|
||||||
|
bulk_update_documents.delay(document_ids=list(affected_docs))
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(Q(id__in=doc_ids) & Q(tags__id=tag)).only("pk")
|
tag_obj = Tag.objects.get(pk=tag)
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
tags_to_remove = [tag_obj, *tag_obj.get_all_descendants()]
|
||||||
|
tag_ids = [t.id for t in tags_to_remove]
|
||||||
|
|
||||||
DocumentTagRelationship = Document.tags.through
|
DocumentTagRelationship = Document.tags.through
|
||||||
|
qs = DocumentTagRelationship.objects.filter(
|
||||||
|
document_id__in=doc_ids,
|
||||||
|
tag_id__in=tag_ids,
|
||||||
|
)
|
||||||
|
affected_docs = list(qs.values_list("document_id", flat=True).distinct())
|
||||||
|
qs.delete()
|
||||||
|
|
||||||
DocumentTagRelationship.objects.filter(
|
if affected_docs:
|
||||||
Q(document_id__in=affected_docs) & Q(tag_id=tag),
|
bulk_update_documents.delay(document_ids=affected_docs)
|
||||||
).delete()
|
|
||||||
|
|
||||||
bulk_update_documents.delay(document_ids=affected_docs)
|
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
@@ -132,23 +148,35 @@ def modify_tags(
|
|||||||
) -> Literal["OK"]:
|
) -> Literal["OK"]:
|
||||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||||
affected_docs = list(qs.values_list("pk", flat=True))
|
affected_docs = list(qs.values_list("pk", flat=True))
|
||||||
|
|
||||||
DocumentTagRelationship = Document.tags.through
|
DocumentTagRelationship = Document.tags.through
|
||||||
|
|
||||||
DocumentTagRelationship.objects.filter(
|
# add with all ancestors
|
||||||
document_id__in=affected_docs,
|
expanded_add_tags: set[int] = set()
|
||||||
tag_id__in=remove_tags,
|
for tag_id in add_tags:
|
||||||
).delete()
|
t = Tag.objects.get(pk=tag_id)
|
||||||
|
expanded_add_tags.update([t.id for t in [t, *t.get_all_ancestors()]])
|
||||||
|
|
||||||
DocumentTagRelationship.objects.bulk_create(
|
# remove with all descendants
|
||||||
[
|
expanded_remove_tags: set[int] = set()
|
||||||
DocumentTagRelationship(document_id=doc, tag_id=tag)
|
for tag_id in remove_tags:
|
||||||
for (doc, tag) in itertools.product(affected_docs, add_tags)
|
t = Tag.objects.get(pk=tag_id)
|
||||||
],
|
expanded_remove_tags.update([t.id for t in [t, *t.get_all_descendants()]])
|
||||||
ignore_conflicts=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
bulk_update_documents.delay(document_ids=affected_docs)
|
if expanded_remove_tags:
|
||||||
|
DocumentTagRelationship.objects.filter(
|
||||||
|
document_id__in=affected_docs,
|
||||||
|
tag_id__in=expanded_remove_tags,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
to_create = [
|
||||||
|
DocumentTagRelationship(document_id=doc, tag_id=tag)
|
||||||
|
for (doc, tag) in itertools.product(affected_docs, expanded_add_tags)
|
||||||
|
]
|
||||||
|
if to_create:
|
||||||
|
DocumentTagRelationship.objects.bulk_create(to_create, ignore_conflicts=True)
|
||||||
|
|
||||||
|
if affected_docs:
|
||||||
|
bulk_update_documents.delay(document_ids=affected_docs)
|
||||||
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|||||||
@@ -689,7 +689,7 @@ class ConsumerPlugin(
|
|||||||
|
|
||||||
if self.metadata.tag_ids:
|
if self.metadata.tag_ids:
|
||||||
for tag_id in self.metadata.tag_ids:
|
for tag_id in self.metadata.tag_ids:
|
||||||
document.tags.add(Tag.objects.get(pk=tag_id))
|
document.add_nested_tags([Tag.objects.get(pk=tag_id)])
|
||||||
|
|
||||||
if self.metadata.storage_path_id:
|
if self.metadata.storage_path_id:
|
||||||
document.storage_path = StoragePath.objects.get(
|
document.storage_path = StoragePath.objects.get(
|
||||||
|
|||||||
26
src/documents/migrations/1069_tag_parent.py
Normal file
26
src/documents/migrations/1069_tag_parent.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2025-02-10 06:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "1068_alter_document_created"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="tag",
|
||||||
|
name="parent",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="children",
|
||||||
|
to="documents.tag",
|
||||||
|
verbose_name="parent",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -7,6 +7,7 @@ from celery import states
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
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.db import models
|
from django.db import models
|
||||||
@@ -108,10 +109,38 @@ class Tag(MatchingModel):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
"self",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="children",
|
||||||
|
verbose_name=_("parent"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(MatchingModel.Meta):
|
class Meta(MatchingModel.Meta):
|
||||||
verbose_name = _("tag")
|
verbose_name = _("tag")
|
||||||
verbose_name_plural = _("tags")
|
verbose_name_plural = _("tags")
|
||||||
|
|
||||||
|
def get_all_descendants(self):
|
||||||
|
descendants = []
|
||||||
|
for child in self.children.all():
|
||||||
|
descendants.append(child)
|
||||||
|
descendants.extend(child.get_all_descendants())
|
||||||
|
return descendants
|
||||||
|
|
||||||
|
def get_all_ancestors(self):
|
||||||
|
ancestors = []
|
||||||
|
if self.parent:
|
||||||
|
ancestors.append(self.parent)
|
||||||
|
ancestors.extend(self.parent.get_all_ancestors())
|
||||||
|
return ancestors
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.parent == self:
|
||||||
|
raise ValidationError("Cannot set itself as parent.")
|
||||||
|
return super().clean()
|
||||||
|
|
||||||
|
|
||||||
class DocumentType(MatchingModel):
|
class DocumentType(MatchingModel):
|
||||||
class Meta(MatchingModel.Meta):
|
class Meta(MatchingModel.Meta):
|
||||||
@@ -398,6 +427,12 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
|||||||
def created_date(self):
|
def created_date(self):
|
||||||
return self.created
|
return self.created
|
||||||
|
|
||||||
|
def add_nested_tags(self, tags):
|
||||||
|
for tag in tags:
|
||||||
|
self.tags.add(tag)
|
||||||
|
if tag.parent:
|
||||||
|
self.add_nested_tags([tag.parent])
|
||||||
|
|
||||||
|
|
||||||
class SavedView(ModelWithOwner):
|
class SavedView(ModelWithOwner):
|
||||||
class DisplayMode(models.TextChoices):
|
class DisplayMode(models.TextChoices):
|
||||||
|
|||||||
@@ -540,6 +540,18 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
|||||||
|
|
||||||
text_color = serializers.SerializerMethodField()
|
text_color = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
children = SerializerMethodField()
|
||||||
|
|
||||||
|
@extend_schema_field(
|
||||||
|
field=serializers.ListSerializer(
|
||||||
|
child=serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Tag.objects.all(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def get_children(self, obj):
|
||||||
|
return TagSerializer(obj.children.all(), many=True).data
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = (
|
fields = (
|
||||||
@@ -557,6 +569,8 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
|||||||
"permissions",
|
"permissions",
|
||||||
"user_can_change",
|
"user_can_change",
|
||||||
"set_permissions",
|
"set_permissions",
|
||||||
|
"parent",
|
||||||
|
"children",
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_color(self, color):
|
def validate_color(self, color):
|
||||||
@@ -1028,6 +1042,23 @@ class DocumentSerializer(
|
|||||||
custom_field_instance.field,
|
custom_field_instance.field,
|
||||||
doc_id,
|
doc_id,
|
||||||
)
|
)
|
||||||
|
if "tags" in validated_data:
|
||||||
|
# add all parent tags
|
||||||
|
all_ancestor_tags = set(validated_data["tags"])
|
||||||
|
for tag in validated_data["tags"]:
|
||||||
|
all_ancestor_tags.update(tag.get_all_ancestors())
|
||||||
|
validated_data["tags"] = list(all_ancestor_tags)
|
||||||
|
# remove any children for parents that are being removed
|
||||||
|
tag_parents_being_removed = [
|
||||||
|
tag
|
||||||
|
for tag in instance.tags.all()
|
||||||
|
if tag not in validated_data["tags"] and tag.children.count() > 0
|
||||||
|
]
|
||||||
|
validated_data["tags"] = [
|
||||||
|
tag
|
||||||
|
for tag in validated_data["tags"]
|
||||||
|
if tag not in tag_parents_being_removed
|
||||||
|
]
|
||||||
if validated_data.get("remove_inbox_tags"):
|
if validated_data.get("remove_inbox_tags"):
|
||||||
tag_ids_being_added = (
|
tag_ids_being_added = (
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ def set_tags(
|
|||||||
extra={"group": logging_group},
|
extra={"group": logging_group},
|
||||||
)
|
)
|
||||||
|
|
||||||
document.tags.add(*relevant_tags)
|
document.add_nested_tags(relevant_tags)
|
||||||
|
|
||||||
|
|
||||||
def set_storage_path(
|
def set_storage_path(
|
||||||
@@ -767,14 +767,17 @@ def run_workflows(
|
|||||||
|
|
||||||
def assignment_action():
|
def assignment_action():
|
||||||
if action.assign_tags.exists():
|
if action.assign_tags.exists():
|
||||||
|
tag_ids_to_add: set[int] = set()
|
||||||
|
for tag in action.assign_tags.all():
|
||||||
|
tag_ids_to_add.add(tag.pk)
|
||||||
|
tag_ids_to_add.update(t.pk for t in tag.get_all_ancestors())
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
doc_tag_ids.extend(action.assign_tags.values_list("pk", flat=True))
|
doc_tag_ids[:] = list(set(doc_tag_ids) | tag_ids_to_add)
|
||||||
else:
|
else:
|
||||||
if overrides.tag_ids is None:
|
if overrides.tag_ids is None:
|
||||||
overrides.tag_ids = []
|
overrides.tag_ids = []
|
||||||
overrides.tag_ids.extend(
|
overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add)
|
||||||
action.assign_tags.values_list("pk", flat=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
if action.assign_correspondent:
|
if action.assign_correspondent:
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
@@ -917,14 +920,17 @@ def run_workflows(
|
|||||||
else:
|
else:
|
||||||
overrides.tag_ids = None
|
overrides.tag_ids = None
|
||||||
else:
|
else:
|
||||||
|
tag_ids_to_remove: set[int] = set()
|
||||||
|
for tag in action.remove_tags.all():
|
||||||
|
tag_ids_to_remove.add(tag.pk)
|
||||||
|
tag_ids_to_remove.update(t.pk for t in tag.get_all_descendants())
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
for tag in action.remove_tags.filter(
|
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
|
||||||
pk__in=document.tags.values_list("pk", flat=True),
|
|
||||||
):
|
|
||||||
doc_tag_ids.remove(tag.pk)
|
|
||||||
elif overrides.tag_ids:
|
elif overrides.tag_ids:
|
||||||
for tag in action.remove_tags.filter(pk__in=overrides.tag_ids):
|
overrides.tag_ids = [
|
||||||
overrides.tag_ids.remove(tag.pk)
|
t for t in overrides.tag_ids if t not in tag_ids_to_remove
|
||||||
|
]
|
||||||
|
|
||||||
if not use_overrides and (
|
if not use_overrides and (
|
||||||
action.remove_all_correspondents
|
action.remove_all_correspondents
|
||||||
|
|||||||
112
src/documents/tests/test_tag_hierarchy.py
Normal file
112
src/documents/tests/test_tag_hierarchy.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from documents import bulk_edit
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import Tag
|
||||||
|
from documents.models import Workflow
|
||||||
|
from documents.models import WorkflowAction
|
||||||
|
from documents.models import WorkflowTrigger
|
||||||
|
from documents.signals.handlers import run_workflows
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagHierarchy(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser(username="admin")
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
self.parent = Tag.objects.create(name="Parent")
|
||||||
|
self.child = Tag.objects.create(name="Child", parent=self.parent)
|
||||||
|
|
||||||
|
patcher = mock.patch("documents.bulk_edit.bulk_update_documents.delay")
|
||||||
|
self.async_task = patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
self.document = Document.objects.create(
|
||||||
|
title="doc",
|
||||||
|
content="",
|
||||||
|
checksum="1",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_add_child_adds_parent(self):
|
||||||
|
self.client.patch(
|
||||||
|
f"/api/documents/{self.document.pk}/",
|
||||||
|
{"tags": [self.child.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
tags = set(self.document.tags.values_list("pk", flat=True))
|
||||||
|
assert tags == {self.parent.pk, self.child.pk}
|
||||||
|
|
||||||
|
def test_api_remove_parent_removes_child(self):
|
||||||
|
self.document.add_nested_tags([self.child])
|
||||||
|
self.client.patch(
|
||||||
|
f"/api/documents/{self.document.pk}/",
|
||||||
|
{"tags": []},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
def test_bulk_edit_respects_hierarchy(self):
|
||||||
|
bulk_edit.add_tag([self.document.pk], self.child.pk)
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
tags = set(self.document.tags.values_list("pk", flat=True))
|
||||||
|
assert tags == {self.parent.pk, self.child.pk}
|
||||||
|
|
||||||
|
bulk_edit.remove_tag([self.document.pk], self.parent.pk)
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
bulk_edit.modify_tags([self.document.pk], [self.child.pk], [])
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
tags = set(self.document.tags.values_list("pk", flat=True))
|
||||||
|
assert tags == {self.parent.pk, self.child.pk}
|
||||||
|
|
||||||
|
bulk_edit.modify_tags([self.document.pk], [], [self.parent.pk])
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
def test_workflow_actions(self):
|
||||||
|
workflow = Workflow.objects.create(name="wf", order=0)
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
assign_action = WorkflowAction.objects.create()
|
||||||
|
assign_action.assign_tags.add(self.child)
|
||||||
|
workflow.triggers.add(trigger)
|
||||||
|
workflow.actions.add(assign_action)
|
||||||
|
|
||||||
|
run_workflows(trigger.type, self.document)
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
tags = set(self.document.tags.values_list("pk", flat=True))
|
||||||
|
assert tags == {self.parent.pk, self.child.pk}
|
||||||
|
|
||||||
|
# removal
|
||||||
|
removal_action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
||||||
|
)
|
||||||
|
removal_action.remove_tags.add(self.parent)
|
||||||
|
workflow.actions.clear()
|
||||||
|
workflow.actions.add(removal_action)
|
||||||
|
|
||||||
|
run_workflows(trigger.type, self.document)
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
assert self.document.tags.count() == 0
|
||||||
|
|
||||||
|
def test_tag_view_parent_update_adds_parent_to_docs(self):
|
||||||
|
orphan = Tag.objects.create(name="Orphan")
|
||||||
|
self.document.tags.add(orphan)
|
||||||
|
|
||||||
|
self.client.patch(
|
||||||
|
f"/api/tags/{orphan.pk}/",
|
||||||
|
{"parent": self.parent.pk},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.document.refresh_from_db()
|
||||||
|
tags = set(self.document.tags.values_list("pk", flat=True))
|
||||||
|
assert tags == {self.parent.pk, orphan.pk}
|
||||||
@@ -341,6 +341,39 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
|||||||
filterset_class = TagFilterSet
|
filterset_class = TagFilterSet
|
||||||
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
|
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
old_parent = self.get_object().parent
|
||||||
|
tag = serializer.save()
|
||||||
|
new_parent = tag.parent
|
||||||
|
if old_parent != new_parent:
|
||||||
|
self._update_document_parent_tags(tag, old_parent, new_parent)
|
||||||
|
|
||||||
|
def _update_document_parent_tags(self, tag, old_parent, new_parent):
|
||||||
|
DocumentTagRelationship = Document.tags.through
|
||||||
|
doc_ids = list(Document.objects.filter(tags=tag).values_list("pk", flat=True))
|
||||||
|
affected = set()
|
||||||
|
|
||||||
|
if new_parent:
|
||||||
|
parents_to_add = [new_parent, *new_parent.get_all_ancestors()]
|
||||||
|
to_create = []
|
||||||
|
for parent in parents_to_add:
|
||||||
|
missing = Document.objects.filter(id__in=doc_ids).exclude(tags=parent)
|
||||||
|
to_create.extend(
|
||||||
|
DocumentTagRelationship(document_id=doc_id, tag_id=parent.id)
|
||||||
|
for doc_id in missing.values_list("pk", flat=True)
|
||||||
|
)
|
||||||
|
affected.update(missing.values_list("pk", flat=True))
|
||||||
|
if to_create:
|
||||||
|
DocumentTagRelationship.objects.bulk_create(
|
||||||
|
to_create,
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if affected:
|
||||||
|
from documents.tasks import bulk_update_documents
|
||||||
|
|
||||||
|
bulk_update_documents.delay(document_ids=list(affected))
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
||||||
class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||||
|
|||||||
Reference in New Issue
Block a user