diff --git a/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html index 7aac54606..a5c2d9416 100644 --- a/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.html @@ -12,6 +12,8 @@ + + @if (patternRequired) { diff --git a/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.ts index 63a99e2f2..67864227d 100644 --- a/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component.ts @@ -36,6 +36,8 @@ import { TextComponent } from '../../input/text/text.component' ], }) export class TagEditDialogComponent extends EditDialogComponent { + tags: Tag[] + constructor( service: TagService, activeModal: NgbActiveModal, @@ -43,6 +45,10 @@ export class TagEditDialogComponent extends EditDialogComponent { settingsService: SettingsService ) { super(service, activeModal, userService, settingsService) + + this.service.listAll().subscribe((result) => { + this.tags = result.results + }) } getCreateTitle() { @@ -58,6 +64,7 @@ export class TagEditDialogComponent extends EditDialogComponent { 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), diff --git a/src-ui/src/app/components/common/input/tags/tags.component.html b/src-ui/src/app/components/common/input/tags/tags.component.html index 23c680dd0..f368b5a2c 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.html +++ b/src-ui/src/app/components/common/input/tags/tags.component.html @@ -7,7 +7,7 @@
() diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.html b/src-ui/src/app/components/manage/management-list/management-list.component.html index 82fd8502c..349e1cfe8 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.html +++ b/src-ui/src/app/components/manage/management-list/management-list.component.html @@ -53,59 +53,7 @@ } @for (object of data; track object) { - - -
- - -
- - - {{ getMatching(object) }} - {{ object.document_count }} - @for (column of extraColumns; track column) { - - @if (column.rendersHtml) { -
- } @else { - {{ column.valueFn.call(null, object) }} - } - - } - -
-
-
- -
- - - @if (object.document_count > 0) { - - } -
-
-
-
- - -
- @if (object.document_count > 0) { -
- -
- } -
- - + } @@ -126,3 +74,67 @@ }
} + + + + +
+ + +
+ + + + + {{ getMatching(object) }} + {{ object.document_count }} + @for (column of extraColumns; track column) { + + @if (column.rendersHtml) { +
+ } @else { + {{ column.valueFn.call(null, object) }} + } + + } + +
+
+
+ +
+ + + @if (object.document_count > 0) { + + } +
+
+
+
+ + +
+ @if (object.document_count > 0) { +
+ +
+ } +
+ + + + @if (object.children && object.children.length > 0) { + @for (child of object.children; track child) { + + } + } +
diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.scss b/src-ui/src/app/components/manage/management-list/management-list.component.scss index aa2871d68..98809b59f 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.scss +++ b/src-ui/src/app/components/manage/management-list/management-list.component.scss @@ -10,3 +10,27 @@ tbody tr:last-child td { .form-check { min-height: 0; } + +.depth-0 { + padding-left: 0; +} + +.depth-1 { + padding-left: 20px; +} + +.depth-2 { + padding-left: 40px; +} + +.depth-3 { + padding-left: 60px; +} + +.depth-4 { + padding-left: 80px; +} + +.depth-5 { + padding-left: 100px; +} diff --git a/src-ui/src/app/components/manage/management-list/management-list.component.ts b/src-ui/src/app/components/manage/management-list/management-list.component.ts index 7f7721485..785f9da52 100644 --- a/src-ui/src/app/components/manage/management-list/management-list.component.ts +++ b/src-ui/src/app/components/manage/management-list/management-list.component.ts @@ -131,6 +131,10 @@ export abstract class ManagementListComponent this.reloadData() } + protected filterData(data: T[]): T[] { + return data + } + reloadData(extraParams: { [key: string]: any } = null) { this.loading = true this.clearSelection() @@ -147,7 +151,7 @@ export abstract class ManagementListComponent .pipe( takeUntil(this.unsubscribeNotifier), tap((c) => { - this.data = c.results + this.data = this.filterData(c.results) this.collectionSize = c.count }), delay(100) diff --git a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts index f0d7e7959..20b4e47b6 100644 --- a/src-ui/src/app/components/manage/tag-list/tag-list.component.ts +++ b/src-ui/src/app/components/manage/tag-list/tag-list.component.ts @@ -1,4 +1,4 @@ -import { NgClass, TitleCasePipe } from '@angular/common' +import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common' import { Component } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { @@ -36,6 +36,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp FormsModule, ReactiveFormsModule, NgClass, + NgTemplateOutlet, NgbDropdownModule, NgbPaginationModule, NgxBootstrapIconsModule, @@ -76,4 +77,8 @@ export class TagListComponent extends ManagementListComponent { getDeleteMessage(object: Tag) { return $localize`Do you really want to delete the tag "${object.name}"?` } + + filterData(data: Tag[]) { + return data.filter((tag) => !tag.parent) + } } diff --git a/src-ui/src/app/data/tag.ts b/src-ui/src/app/data/tag.ts index 478dc674c..164ee8589 100644 --- a/src-ui/src/app/data/tag.ts +++ b/src-ui/src/app/data/tag.ts @@ -6,4 +6,8 @@ export interface Tag extends MatchingModel { text_color?: string is_inbox_tag?: boolean + + parent?: number // Tag ID + + children?: Tag[] // read-only } diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 81739fa7a..f09995c65 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -775,7 +775,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( diff --git a/src/documents/migrations/1063_tag_parent.py b/src/documents/migrations/1063_tag_parent.py new file mode 100644 index 000000000..8158f7668 --- /dev/null +++ b/src/documents/migrations/1063_tag_parent.py @@ -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", "1062_alter_savedviewfilterrule_rule_type"), + ] + + 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", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 4c644c14c..ff0714d17 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -12,6 +12,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 @@ -113,10 +114,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): @@ -378,6 +407,12 @@ class Document(SoftDeleteModel, ModelWithOwner): def created_date(self): return timezone.localdate(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 Log(models.Model): LEVELS = ( diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 6a0a1eec1..0e5693be9 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -528,6 +528,11 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer): text_color = serializers.SerializerMethodField() + children = SerializerMethodField() + + def get_children(self, obj): + return TagSerializer(obj.children.all(), many=True).data + class Meta: model = Tag fields = ( @@ -545,6 +550,8 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer): "permissions", "user_can_change", "set_permissions", + "parent", + "children", ) def validate_color(self, color): @@ -952,6 +959,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 = ( [ diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 1c4d36694..fa940d89c 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -248,7 +248,7 @@ def set_tags( extra={"group": logging_group}, ) - document.tags.add(*relevant_tags) + document.add_nested_tags(relevant_tags) def set_storage_path(