Compare commits

..

5 Commits

21 changed files with 116 additions and 470 deletions

View File

@@ -10,10 +10,8 @@ component_management:
paths:
- src-ui/**
# https://docs.codecov.com/docs/pull-request-comments
# codecov will only comment if coverage changes
comment:
layout: "header, diff, components, flags, files"
require_changes: true
# https://docs.codecov.com/docs/javascript-bundle-analysis
require_bundle_changes: true
bundle_change_threshold: "50Kb"

View File

@@ -33,7 +33,7 @@ warns that
`OCR for XX failed, but we're going to stick with what we've got since FORGIVING_OCR is enabled`,
then you might need to install the [Tesseract language
files](https://packages.ubuntu.com/search?keywords=tesseract-ocr)
marching your document's languages.
matching your document's languages.
As an example, if you are running Paperless-ngx from any Ubuntu or
Debian box, and your documents are written in Spanish you may need to

View File

@@ -11,7 +11,7 @@
<div class="selected-icon">
@if (createdRelativeDate) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}

View File

@@ -12,8 +12,6 @@
<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) {

View File

@@ -35,16 +35,11 @@ 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() {
@@ -60,7 +55,6 @@ 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),

View File

@@ -7,14 +7,13 @@
<div class="input-group flex-nowrap">
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
[disabled]="disabled"
[multiple]="multiple"
[multiple]="true"
[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">

View File

@@ -100,9 +100,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
horizontal: boolean = false
@Input()
multiple: boolean = true
@Output()
filterDocuments = new EventEmitter<Tag[]>()
@@ -127,40 +124,13 @@ 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',

View File

@@ -54,7 +54,61 @@
</tr>
}
@for (object of data; track object) {
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
<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>&nbsp;<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>&nbsp;<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>&nbsp;<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>
</table>
@@ -75,72 +129,3 @@
}
</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>&nbsp;<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>&nbsp;<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>&nbsp;<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>

View File

@@ -10,17 +10,3 @@ 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;
}
}

View File

@@ -79,7 +79,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
public data: T[] = []
private unfilteredData: T[] = []
public page = 1
@@ -133,18 +132,6 @@ 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()
@@ -161,8 +148,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((c) => {
this.unfilteredData = c.results
this.data = this.filterData(c.results)
this.data = c.results
this.collectionSize = c.count
}),
delay(100)

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { NgClass, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
@@ -30,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
FormsModule,
ReactiveFormsModule,
NgClass,
NgTemplateOutlet,
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
@@ -60,8 +59,4 @@ 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)
}
}

View File

@@ -6,8 +6,4 @@ export interface Tag extends MatchingModel {
text_color?: string
is_inbox_tag?: boolean
parent?: number // Tag ID
children?: Tag[] // read-only
}

View File

@@ -25,7 +25,6 @@ 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
@@ -97,46 +96,31 @@ def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Litera
def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
tag_obj = Tag.objects.get(pk=tag)
tags_to_add = [tag_obj, *tag_obj.get_all_ancestors()]
qs = Document.objects.filter(Q(id__in=doc_ids) & ~Q(tags__id=tag)).only("pk")
affected_docs = list(qs.values_list("pk", flat=True))
DocumentTagRelationship = Document.tags.through
to_create = []
affected_docs: set[int] = set()
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
)
DocumentTagRelationship.objects.bulk_create(
[DocumentTagRelationship(document_id=doc, tag_id=tag) for doc in affected_docs],
)
if to_create:
DocumentTagRelationship.objects.bulk_create(to_create)
if affected_docs:
bulk_update_documents.delay(document_ids=list(affected_docs))
bulk_update_documents.delay(document_ids=affected_docs)
return "OK"
def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
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]
qs = Document.objects.filter(Q(id__in=doc_ids) & Q(tags__id=tag)).only("pk")
affected_docs = list(qs.values_list("pk", flat=True))
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()
if affected_docs:
bulk_update_documents.delay(document_ids=affected_docs)
DocumentTagRelationship.objects.filter(
Q(document_id__in=affected_docs) & Q(tag_id=tag),
).delete()
bulk_update_documents.delay(document_ids=affected_docs)
return "OK"
@@ -148,35 +132,23 @@ 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
# 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.filter(
document_id__in=affected_docs,
tag_id__in=remove_tags,
).delete()
# 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()]])
DocumentTagRelationship.objects.bulk_create(
[
DocumentTagRelationship(document_id=doc, tag_id=tag)
for (doc, tag) in itertools.product(affected_docs, add_tags)
],
ignore_conflicts=True,
)
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)
bulk_update_documents.delay(document_ids=affected_docs)
return "OK"

View File

@@ -689,7 +689,7 @@ class ConsumerPlugin(
if self.metadata.tag_ids:
for tag_id in self.metadata.tag_ids:
document.add_nested_tags([Tag.objects.get(pk=tag_id)])
document.tags.add(Tag.objects.get(pk=tag_id))
if self.metadata.storage_path_id:
document.storage_path = StoragePath.objects.get(

View File

@@ -1,26 +0,0 @@
# 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",
),
),
]

View File

@@ -7,7 +7,6 @@ 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
@@ -109,38 +108,10 @@ 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):
@@ -405,12 +376,6 @@ 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):

View File

@@ -540,18 +540,6 @@ 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 = (
@@ -569,8 +557,6 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
"permissions",
"user_can_change",
"set_permissions",
"parent",
"children",
)
def validate_color(self, color):
@@ -1042,23 +1028,6 @@ 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 = (
[

View File

@@ -260,7 +260,7 @@ def set_tags(
extra={"group": logging_group},
)
document.add_nested_tags(relevant_tags)
document.tags.add(*relevant_tags)
def set_storage_path(
@@ -767,17 +767,14 @@ 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[:] = list(set(doc_tag_ids) | tag_ids_to_add)
doc_tag_ids.extend(action.assign_tags.values_list("pk", flat=True))
else:
if overrides.tag_ids is None:
overrides.tag_ids = []
overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add)
overrides.tag_ids.extend(
action.assign_tags.values_list("pk", flat=True),
)
if action.assign_correspondent:
if not use_overrides:
@@ -920,17 +917,14 @@ 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:
doc_tag_ids[:] = [t for t in doc_tag_ids if t not in tag_ids_to_remove]
for tag in action.remove_tags.filter(
pk__in=document.tags.values_list("pk", flat=True),
):
doc_tag_ids.remove(tag.pk)
elif overrides.tag_ids:
overrides.tag_ids = [
t for t in overrides.tag_ids if t not in tag_ids_to_remove
]
for tag in action.remove_tags.filter(pk__in=overrides.tag_ids):
overrides.tag_ids.remove(tag.pk)
if not use_overrides and (
action.remove_all_correspondents

View File

@@ -1,112 +0,0 @@
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}

View File

@@ -341,39 +341,6 @@ 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):
@@ -2869,6 +2836,11 @@ class SystemStatusView(PassUserMixin):
last_trained_task = (
PaperlessTask.objects.filter(
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
status__in=[
states.SUCCESS,
states.FAILURE,
states.REVOKED,
], # ignore running tasks
)
.order_by("-date_done")
.first()
@@ -2878,7 +2850,7 @@ class SystemStatusView(PassUserMixin):
if last_trained_task is None:
classifier_status = "WARNING"
classifier_error = "No classifier training tasks found"
elif last_trained_task and last_trained_task.status == states.FAILURE:
elif last_trained_task and last_trained_task.status != states.SUCCESS:
classifier_status = "ERROR"
classifier_error = last_trained_task.result
classifier_last_trained = (
@@ -2888,6 +2860,11 @@ class SystemStatusView(PassUserMixin):
last_sanity_check = (
PaperlessTask.objects.filter(
task_name=PaperlessTask.TaskName.CHECK_SANITY,
status__in=[
states.SUCCESS,
states.FAILURE,
states.REVOKED,
], # ignore running tasks
)
.order_by("-date_done")
.first()
@@ -2897,7 +2874,7 @@ class SystemStatusView(PassUserMixin):
if last_sanity_check is None:
sanity_check_status = "WARNING"
sanity_check_error = "No sanity check tasks found"
elif last_sanity_check and last_sanity_check.status == states.FAILURE:
elif last_sanity_check and last_sanity_check.status != states.SUCCESS:
sanity_check_status = "ERROR"
sanity_check_error = last_sanity_check.result
sanity_check_last_run = (

View File

@@ -1205,8 +1205,8 @@ def _ocr_to_dateparser_languages(ocr_languages: str) -> list[str]:
language_part = ocr_to_dateparser.get(ocr_lang_part)
if language_part is None:
logger.warning(
f'Skipping unknown OCR language "{ocr_language}" — no dateparser equivalent.',
logger.debug(
f'Unable to map OCR language "{ocr_lang_part}" to dateparser locale. ',
)
continue
@@ -1219,7 +1219,7 @@ def _ocr_to_dateparser_languages(ocr_languages: str) -> list[str]:
try:
loader.get_locale_map(locales=[dateparser_language])
except Exception:
logger.warning(
logger.info(
f"Language variant '{dateparser_language}' not supported by dateparser; falling back to base language '{language_part}'. You can manually set PAPERLESS_DATE_PARSER_LANGUAGES if needed.",
)
dateparser_language = language_part
@@ -1229,12 +1229,12 @@ def _ocr_to_dateparser_languages(ocr_languages: str) -> list[str]:
result.append(dateparser_language)
except Exception as e:
logger.warning(
f"Could not configure dateparser languages. Set PAPERLESS_DATE_PARSER_LANGUAGES parameter to avoid this. Detail: {e}",
f"Error auto-configuring dateparser languages. Set PAPERLESS_DATE_PARSER_LANGUAGES parameter to avoid this. Detail: {e}",
)
return []
if not result:
logger.warning(
"Could not configure any dateparser languages from OCR_LANGUAGE fallback to autodetection.",
logger.info(
"Unable to automatically determine dateparser languages from OCR_LANGUAGE, falling back to multi-language support.",
)
return result