mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Such a mess
This commit is contained in:
		@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -36,6 +36,8 @@ import { TextComponent } from '../../input/text/text.component'
 | 
			
		||||
  ],
 | 
			
		||||
})
 | 
			
		||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
 | 
			
		||||
  tags: Tag[]
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    service: TagService,
 | 
			
		||||
    activeModal: NgbActiveModal,
 | 
			
		||||
@@ -43,6 +45,10 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
 | 
			
		||||
    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<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),
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@
 | 
			
		||||
      <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"
 | 
			
		||||
 
 | 
			
		||||
@@ -99,6 +99,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
 | 
			
		||||
  @Input()
 | 
			
		||||
  horizontal: boolean = false
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  multiple: boolean = true
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  filterDocuments = new EventEmitter<Tag[]>()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -53,6 +53,29 @@
 | 
			
		||||
        </tr>
 | 
			
		||||
      }
 | 
			
		||||
      @for (object of data; track object) {
 | 
			
		||||
        <ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
 | 
			
		||||
      }
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@if (!loading) {
 | 
			
		||||
  <div class="d-flex mb-2">
 | 
			
		||||
    @if (collectionSize > 0) {
 | 
			
		||||
      <div>
 | 
			
		||||
        <ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
 | 
			
		||||
        @if (selectedObjects.size > 0) {
 | 
			
		||||
           ({{selectedObjects.size}} selected)
 | 
			
		||||
        }
 | 
			
		||||
      </div>
 | 
			
		||||
    }
 | 
			
		||||
    @if (collectionSize > 20) {
 | 
			
		||||
      <ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
 | 
			
		||||
    }
 | 
			
		||||
  </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">
 | 
			
		||||
@@ -60,7 +83,9 @@
 | 
			
		||||
        <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="depth-{{depth}}">
 | 
			
		||||
      <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) {
 | 
			
		||||
@@ -106,23 +131,10 @@
 | 
			
		||||
      </div>
 | 
			
		||||
    </td>
 | 
			
		||||
  </tr>
 | 
			
		||||
      }
 | 
			
		||||
    </tbody>
 | 
			
		||||
  </table>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
@if (!loading) {
 | 
			
		||||
  <div class="d-flex mb-2">
 | 
			
		||||
    @if (collectionSize > 0) {
 | 
			
		||||
      <div>
 | 
			
		||||
        <ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
 | 
			
		||||
        @if (selectedObjects.size > 0) {
 | 
			
		||||
           ({{selectedObjects.size}} selected)
 | 
			
		||||
  @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>
 | 
			
		||||
    }
 | 
			
		||||
      </div>
 | 
			
		||||
  }
 | 
			
		||||
    @if (collectionSize > 20) {
 | 
			
		||||
      <ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
 | 
			
		||||
    }
 | 
			
		||||
  </div>
 | 
			
		||||
}
 | 
			
		||||
</ng-template>
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -131,6 +131,10 @@ export abstract class ManagementListComponent<T extends MatchingModel>
 | 
			
		||||
    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<T extends MatchingModel>
 | 
			
		||||
      .pipe(
 | 
			
		||||
        takeUntil(this.unsubscribeNotifier),
 | 
			
		||||
        tap((c) => {
 | 
			
		||||
          this.data = 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 } 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<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,8 @@ export interface Tag extends MatchingModel {
 | 
			
		||||
  text_color?: string
 | 
			
		||||
 | 
			
		||||
  is_inbox_tag?: boolean
 | 
			
		||||
 | 
			
		||||
  parent?: number // Tag ID
 | 
			
		||||
 | 
			
		||||
  children?: Tag[] // read-only
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										26
									
								
								src/documents/migrations/1063_tag_parent.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/documents/migrations/1063_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", "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",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -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 = (
 | 
			
		||||
 
 | 
			
		||||
@@ -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 = (
 | 
			
		||||
                [
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user