mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05: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,59 +53,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 { | ||||
|                 {{ 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> | ||||
| @@ -126,3 +74,67 @@ | ||||
|     } | ||||
|   </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="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) { | ||||
|       <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 { | ||||
|           {{ 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> | ||||
|  | ||||
|   @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,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
	 shamoon
					shamoon