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-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-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (patternRequired) {
|
||||
|
||||
@@ -35,11 +35,16 @@ import { TextComponent } from '../../input/text/text.component'
|
||||
],
|
||||
})
|
||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||
tags: Tag[]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.service = inject(TagService)
|
||||
this.userService = inject(UserService)
|
||||
this.settingsService = inject(SettingsService)
|
||||
this.service.listAll().subscribe((result) => {
|
||||
this.tags = result.results
|
||||
})
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
@@ -55,6 +60,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||
name: new FormControl(''),
|
||||
color: new FormControl(randomColor()),
|
||||
is_inbox_tag: new FormControl(false),
|
||||
parent: new FormControl(null),
|
||||
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
|
||||
match: new FormControl(''),
|
||||
is_insensitive: new FormControl(true),
|
||||
|
||||
@@ -114,6 +114,13 @@ export class FilterableDropdownSelectionModel {
|
||||
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
||||
) {
|
||||
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 (
|
||||
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
||||
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
}
|
||||
</div>
|
||||
<div class="me-1">
|
||||
@if (isTag) {
|
||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||
} @else {
|
||||
<small>{{item.name}}</small>
|
||||
<div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1">
|
||||
@if (isTag && getDepth() > 0) {
|
||||
<div class="indicator"></div>
|
||||
}
|
||||
<div>
|
||||
@if (isTag) {
|
||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||
} @else {
|
||||
<small>{{item.name}}</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!hideCount) {
|
||||
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
||||
|
||||
@@ -2,3 +2,19 @@
|
||||
min-width: 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 { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { TagComponent } from '../../tag/tag.component'
|
||||
|
||||
export enum ToggleableItemState {
|
||||
@@ -18,7 +19,7 @@ export enum ToggleableItemState {
|
||||
})
|
||||
export class ToggleableDropdownButtonComponent {
|
||||
@Input()
|
||||
item: MatchingModel
|
||||
item: MatchingModel | Tag
|
||||
|
||||
@Input()
|
||||
state: ToggleableItemState
|
||||
@@ -45,6 +46,10 @@ export class ToggleableDropdownButtonComponent {
|
||||
return 'is_inbox_tag' in this.item
|
||||
}
|
||||
|
||||
getDepth(): number {
|
||||
return (this.item as Tag).depth ?? 0
|
||||
}
|
||||
|
||||
get currentCount(): number {
|
||||
return this.count ?? this.item.document_count
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
<div class="input-group flex-nowrap">
|
||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[multiple]="true"
|
||||
[multiple]="multiple"
|
||||
[closeOnSelect]="false"
|
||||
[clearSearchOnAdd]="true"
|
||||
[hideSelected]="tags.length > 0"
|
||||
[addTag]="allowCreate ? createTagRef : false"
|
||||
addTagText="Add tag"
|
||||
i18n-addTagText
|
||||
(add)="onAdd($event)"
|
||||
(change)="onChange(value)">
|
||||
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
@@ -25,9 +26,20 @@
|
||||
</button>
|
||||
</ng-template>
|
||||
<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) {
|
||||
<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>
|
||||
</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()
|
||||
horizontal: boolean = false
|
||||
|
||||
@Input()
|
||||
multiple: boolean = true
|
||||
|
||||
@Output()
|
||||
filterDocuments = new EventEmitter<Tag[]>()
|
||||
|
||||
@@ -124,13 +127,40 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
|
||||
let index = this.value.indexOf(tagID)
|
||||
if (index > -1) {
|
||||
const tag = this.getTag(tagID)
|
||||
|
||||
// remove tag
|
||||
let oldValue = this.value
|
||||
oldValue.splice(index, 1)
|
||||
|
||||
// remove children
|
||||
oldValue = this.removeChildren(oldValue, tag)
|
||||
|
||||
this.value = [...oldValue]
|
||||
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) {
|
||||
var modal = this.modalService.open(TagEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
@@ -166,6 +196,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
|
||||
addTag(id) {
|
||||
this.value = [...this.value, id]
|
||||
this.onAdd(this.getTag(id))
|
||||
this.onChange(this.value)
|
||||
}
|
||||
|
||||
@@ -180,4 +211,22 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
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 (showParents && tag.parent) {
|
||||
<pngx-tag [tagID]="tag.parent" [clickable]="clickable" [linkTitle]="linkTitle"></pngx-tag>
|
||||
>
|
||||
}
|
||||
@if (!clickable) {
|
||||
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
|
||||
}
|
||||
|
||||
@@ -50,4 +50,7 @@ export class TagComponent {
|
||||
|
||||
@Input()
|
||||
clickable: boolean = false
|
||||
|
||||
@Input()
|
||||
showParents: boolean = false
|
||||
}
|
||||
|
||||
@@ -1204,7 +1204,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(tagListAllSpy).toHaveBeenCalled()
|
||||
|
||||
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)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { SettingsService } from 'src/app/services/settings.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 { 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'
|
||||
@@ -164,7 +165,10 @@ export class BulkEditorComponent
|
||||
this.tagService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.tagSelectionModel.items = result.results))
|
||||
.subscribe(
|
||||
(result) =>
|
||||
(this.tagSelectionModel.items = flattenTags(result.results))
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
@@ -648,7 +652,7 @@ export class BulkEditorComponent
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newTag, tags }) => {
|
||||
this.tagSelectionModel.items = tags.results
|
||||
this.tagSelectionModel.items = flattenTags(tags.results)
|
||||
this.tagSelectionModel.toggle(newTag.id)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -589,7 +589,7 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
@@ -615,7 +615,7 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.Or
|
||||
)
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
@@ -652,7 +652,7 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags)
|
||||
expect(component.tagSelectionModel.getExcludedItems()).toMatchObject(tags)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
|
||||
@@ -97,6 +97,7 @@ import {
|
||||
CustomFieldQueryExpression,
|
||||
} from 'src/app/utils/custom-field-query-element'
|
||||
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
|
||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||
import {
|
||||
CustomFieldQueriesModel,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
@@ -1134,7 +1135,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
this.loadingCountTotal++
|
||||
this.tagService.listAll().subscribe((result) => {
|
||||
this.tagSelectionModel.items = result.results
|
||||
this.tagSelectionModel.items = flattenTags(result.results)
|
||||
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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -28,6 +28,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
|
||||
@@ -54,61 +54,7 @@
|
||||
</tr>
|
||||
}
|
||||
@for (object of data; track object) {
|
||||
<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"><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>
|
||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -129,3 +75,72 @@
|
||||
}
|
||||
</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 {
|
||||
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>
|
||||
|
||||
public data: T[] = []
|
||||
private unfilteredData: T[] = []
|
||||
|
||||
public page = 1
|
||||
|
||||
@@ -132,6 +133,18 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
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) {
|
||||
this.loading = true
|
||||
this.clearSelection()
|
||||
@@ -148,7 +161,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap((c) => {
|
||||
this.data = c.results
|
||||
this.unfilteredData = c.results
|
||||
this.data = this.filterData(c.results)
|
||||
this.collectionSize = c.count
|
||||
}),
|
||||
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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
@@ -59,4 +60,8 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
||||
getDeleteMessage(object: Tag) {
|
||||
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
|
||||
|
||||
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,
|
||||
chevronDoubleLeft,
|
||||
chevronDoubleRight,
|
||||
chevronRight,
|
||||
clipboard,
|
||||
clipboardCheck,
|
||||
clipboardCheckFill,
|
||||
@@ -94,6 +95,7 @@ import {
|
||||
infoCircle,
|
||||
journals,
|
||||
link,
|
||||
listNested,
|
||||
listTask,
|
||||
listUl,
|
||||
microsoft,
|
||||
@@ -264,6 +266,7 @@ const icons = {
|
||||
checkLg,
|
||||
chevronDoubleLeft,
|
||||
chevronDoubleRight,
|
||||
chevronRight,
|
||||
clipboard,
|
||||
clipboardCheck,
|
||||
clipboardCheckFill,
|
||||
@@ -303,6 +306,7 @@ const icons = {
|
||||
infoCircle,
|
||||
journals,
|
||||
link,
|
||||
listNested,
|
||||
listTask,
|
||||
listUl,
|
||||
microsoft,
|
||||
|
||||
@@ -25,6 +25,7 @@ from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.helpers import DocumentsStatusManager
|
||||
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"]:
|
||||
qs = Document.objects.filter(Q(id__in=doc_ids) & ~Q(tags__id=tag)).only("pk")
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
tag_obj = Tag.objects.get(pk=tag)
|
||||
tags_to_add = [tag_obj, *tag_obj.get_all_ancestors()]
|
||||
|
||||
DocumentTagRelationship = Document.tags.through
|
||||
to_create = []
|
||||
affected_docs: set[int] = set()
|
||||
|
||||
DocumentTagRelationship.objects.bulk_create(
|
||||
[DocumentTagRelationship(document_id=doc, tag_id=tag) for doc in affected_docs],
|
||||
)
|
||||
for t in tags_to_add:
|
||||
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"
|
||||
|
||||
|
||||
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")
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
tag_obj = Tag.objects.get(pk=tag)
|
||||
tags_to_remove = [tag_obj, *tag_obj.get_all_descendants()]
|
||||
tag_ids = [t.id for t in tags_to_remove]
|
||||
|
||||
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(
|
||||
Q(document_id__in=affected_docs) & Q(tag_id=tag),
|
||||
).delete()
|
||||
|
||||
bulk_update_documents.delay(document_ids=affected_docs)
|
||||
if affected_docs:
|
||||
bulk_update_documents.delay(document_ids=affected_docs)
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -132,23 +148,35 @@ def modify_tags(
|
||||
) -> Literal["OK"]:
|
||||
qs = Document.objects.filter(id__in=doc_ids).only("pk")
|
||||
affected_docs = list(qs.values_list("pk", flat=True))
|
||||
|
||||
DocumentTagRelationship = Document.tags.through
|
||||
|
||||
DocumentTagRelationship.objects.filter(
|
||||
document_id__in=affected_docs,
|
||||
tag_id__in=remove_tags,
|
||||
).delete()
|
||||
# add with all ancestors
|
||||
expanded_add_tags: set[int] = set()
|
||||
for tag_id in add_tags:
|
||||
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(
|
||||
[
|
||||
DocumentTagRelationship(document_id=doc, tag_id=tag)
|
||||
for (doc, tag) in itertools.product(affected_docs, add_tags)
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
# remove with all descendants
|
||||
expanded_remove_tags: set[int] = set()
|
||||
for tag_id in remove_tags:
|
||||
t = Tag.objects.get(pk=tag_id)
|
||||
expanded_remove_tags.update([t.id for t in [t, *t.get_all_descendants()]])
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -689,7 +689,7 @@ class ConsumerPlugin(
|
||||
|
||||
if 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:
|
||||
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.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.core.validators import MinValueValidator
|
||||
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):
|
||||
verbose_name = _("tag")
|
||||
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 Meta(MatchingModel.Meta):
|
||||
@@ -398,6 +427,12 @@ class Document(SoftDeleteModel, ModelWithOwner):
|
||||
def created_date(self):
|
||||
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 DisplayMode(models.TextChoices):
|
||||
|
||||
@@ -540,6 +540,18 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
|
||||
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:
|
||||
model = Tag
|
||||
fields = (
|
||||
@@ -557,6 +569,8 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
"parent",
|
||||
"children",
|
||||
)
|
||||
|
||||
def validate_color(self, color):
|
||||
@@ -1028,6 +1042,23 @@ class DocumentSerializer(
|
||||
custom_field_instance.field,
|
||||
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"):
|
||||
tag_ids_being_added = (
|
||||
[
|
||||
|
||||
@@ -260,7 +260,7 @@ def set_tags(
|
||||
extra={"group": logging_group},
|
||||
)
|
||||
|
||||
document.tags.add(*relevant_tags)
|
||||
document.add_nested_tags(relevant_tags)
|
||||
|
||||
|
||||
def set_storage_path(
|
||||
@@ -767,14 +767,17 @@ def run_workflows(
|
||||
|
||||
def assignment_action():
|
||||
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:
|
||||
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:
|
||||
if overrides.tag_ids is None:
|
||||
overrides.tag_ids = []
|
||||
overrides.tag_ids.extend(
|
||||
action.assign_tags.values_list("pk", flat=True),
|
||||
)
|
||||
overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add)
|
||||
|
||||
if action.assign_correspondent:
|
||||
if not use_overrides:
|
||||
@@ -917,14 +920,17 @@ def run_workflows(
|
||||
else:
|
||||
overrides.tag_ids = None
|
||||
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:
|
||||
for tag in action.remove_tags.filter(
|
||||
pk__in=document.tags.values_list("pk", flat=True),
|
||||
):
|
||||
doc_tag_ids.remove(tag.pk)
|
||||
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
|
||||
elif overrides.tag_ids:
|
||||
for tag in action.remove_tags.filter(pk__in=overrides.tag_ids):
|
||||
overrides.tag_ids.remove(tag.pk)
|
||||
overrides.tag_ids = [
|
||||
t for t in overrides.tag_ids if t not in tag_ids_to_remove
|
||||
]
|
||||
|
||||
if not use_overrides and (
|
||||
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
|
||||
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))
|
||||
class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
|
||||
Reference in New Issue
Block a user