mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Enhancement: only include correspondent 'last_correspondence' if requested (#6792)
This commit is contained in:
		| @@ -109,13 +109,13 @@ | ||||
|               <pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number> | ||||
|               <pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" | ||||
|               [error]="error?.created_date"></pngx-input-date> | ||||
|               <pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" | ||||
|               <pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Correspondent)" | ||||
|               (createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select> | ||||
|               <pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" | ||||
|               <pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.DocumentType)" | ||||
|               (createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select> | ||||
|               <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" | ||||
|               <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)" | ||||
|               (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select> | ||||
|               <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags> | ||||
|               <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags> | ||||
|               @for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) { | ||||
|                 <div [formGroup]="customFieldFormFields.controls[i]"> | ||||
|                   @switch (getCustomFieldFromInstance(fieldInstance)?.data_type) { | ||||
|   | ||||
| @@ -80,8 +80,9 @@ import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' | ||||
| import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' | ||||
| import { PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component' | ||||
| import { PdfViewerModule } from 'ng2-pdf-viewer' | ||||
| import { DataType } from 'src/app/data/datatype' | ||||
|  | ||||
| const doc: Document = { | ||||
|   id: 3, | ||||
| @@ -783,10 +784,9 @@ describe('DocumentDetailComponent', () => { | ||||
|     const object = { | ||||
|       id: 22, | ||||
|       name: 'Correspondent22', | ||||
|       last_correspondence: new Date().toISOString(), | ||||
|     } as Correspondent | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.filterDocuments([object]) | ||||
|     component.filterDocuments([object], DataType.Correspondent) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { | ||||
|         rule_type: FILTER_CORRESPONDENT, | ||||
| @@ -799,7 +799,7 @@ describe('DocumentDetailComponent', () => { | ||||
|     initNormally() | ||||
|     const object = { id: 22, name: 'DocumentType22' } as DocumentType | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.filterDocuments([object]) | ||||
|     component.filterDocuments([object], DataType.DocumentType) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { | ||||
|         rule_type: FILTER_DOCUMENT_TYPE, | ||||
| @@ -816,7 +816,7 @@ describe('DocumentDetailComponent', () => { | ||||
|       path: '/foo/bar/', | ||||
|     } as StoragePath | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.filterDocuments([object]) | ||||
|     component.filterDocuments([object], DataType.StoragePath) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { | ||||
|         rule_type: FILTER_STORAGE_PATH, | ||||
| @@ -842,7 +842,7 @@ describe('DocumentDetailComponent', () => { | ||||
|       text_color: '#000000', | ||||
|     } as Tag | ||||
|     const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') | ||||
|     component.filterDocuments([object1, object2]) | ||||
|     component.filterDocuments([object1, object2], DataType.Tag) | ||||
|     expect(qfSpy).toHaveBeenCalledWith([ | ||||
|       { | ||||
|         rule_type: FILTER_HAS_TAGS_ALL, | ||||
|   | ||||
| @@ -71,6 +71,7 @@ import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-co | ||||
| import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component' | ||||
| import { HotKeyService } from 'src/app/services/hot-key.service' | ||||
| import { PDFDocumentProxy } from 'ng2-pdf-viewer' | ||||
| import { DataType } from 'src/app/data/datatype' | ||||
|  | ||||
| enum DocumentDetailNavIDs { | ||||
|   Details = 1, | ||||
| @@ -171,6 +172,8 @@ export class DocumentDetailComponent | ||||
|  | ||||
|   public readonly ContentRenderType = ContentRenderType | ||||
|  | ||||
|   public readonly DataType = DataType | ||||
|  | ||||
|   @ViewChild('nav') nav: NgbNav | ||||
|   @ViewChild('pdfPreview') set pdfPreview(element) { | ||||
|     // this gets called when component added or removed from DOM | ||||
| @@ -998,7 +1001,7 @@ export class DocumentDetailComponent | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   filterDocuments(items: ObjectWithId[] | NgbDateStruct[]) { | ||||
|   filterDocuments(items: ObjectWithId[] | NgbDateStruct[], type?: DataType) { | ||||
|     const filterRules: FilterRule[] = items.flatMap((i) => { | ||||
|       if (i.hasOwnProperty('year')) { | ||||
|         const isoDateAdapter = new ISODateAdapter() | ||||
| @@ -1017,30 +1020,28 @@ export class DocumentDetailComponent | ||||
|             value: dateBefore.toISOString().substring(0, 10), | ||||
|           }, | ||||
|         ] | ||||
|       } else if (i.hasOwnProperty('last_correspondence')) { | ||||
|         // Correspondent | ||||
|         return { | ||||
|           rule_type: FILTER_CORRESPONDENT, | ||||
|           value: (i as Correspondent).id.toString(), | ||||
|         } | ||||
|       } else if (i.hasOwnProperty('path')) { | ||||
|         // Storage Path | ||||
|         return { | ||||
|           rule_type: FILTER_STORAGE_PATH, | ||||
|           value: (i as StoragePath).id.toString(), | ||||
|         } | ||||
|       } else if (i.hasOwnProperty('is_inbox_tag')) { | ||||
|         // Tag | ||||
|         return { | ||||
|           rule_type: FILTER_HAS_TAGS_ALL, | ||||
|           value: (i as Tag).id.toString(), | ||||
|         } | ||||
|       } else { | ||||
|         // Document Type, has no specific props | ||||
|         return { | ||||
|           rule_type: FILTER_DOCUMENT_TYPE, | ||||
|           value: (i as DocumentType).id.toString(), | ||||
|         } | ||||
|       } | ||||
|       switch (type) { | ||||
|         case DataType.Correspondent: | ||||
|           return { | ||||
|             rule_type: FILTER_CORRESPONDENT, | ||||
|             value: (i as Correspondent).id.toString(), | ||||
|           } | ||||
|         case DataType.DocumentType: | ||||
|           return { | ||||
|             rule_type: FILTER_DOCUMENT_TYPE, | ||||
|             value: (i as DocumentType).id.toString(), | ||||
|           } | ||||
|         case DataType.StoragePath: | ||||
|           return { | ||||
|             rule_type: FILTER_STORAGE_PATH, | ||||
|             value: (i as StoragePath).id.toString(), | ||||
|           } | ||||
|         case DataType.Tag: | ||||
|           return { | ||||
|             rule_type: FILTER_HAS_TAGS_ALL, | ||||
|             value: (i as Tag).id.toString(), | ||||
|           } | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' | ||||
| import { ManagementListComponent } from '../management-list/management-list.component' | ||||
| import { takeUntil } from 'rxjs' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-correspondent-list', | ||||
| @@ -63,6 +64,26 @@ export class CorrespondentListComponent extends ManagementListComponent<Correspo | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   public reloadData(): void { | ||||
|     this.isLoading = true | ||||
|     this.service | ||||
|       .listFiltered( | ||||
|         this.page, | ||||
|         null, | ||||
|         this.sortField, | ||||
|         this.sortReverse, | ||||
|         this._nameFilter, | ||||
|         true, | ||||
|         { last_correspondence: true } | ||||
|       ) | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((c) => { | ||||
|         this.data = c.results | ||||
|         this.collectionSize = c.count | ||||
|         this.isLoading = false | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   getDeleteMessage(object: Correspondent) { | ||||
|     return $localize`Do you really want to delete the correspondent "${object.name}"?` | ||||
|   } | ||||
|   | ||||
| @@ -52,7 +52,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|   implements OnInit, OnDestroy | ||||
| { | ||||
|   constructor( | ||||
|     private service: AbstractNameFilterService<T>, | ||||
|     protected service: AbstractNameFilterService<T>, | ||||
|     private modalService: NgbModal, | ||||
|     private editDialogComponent: any, | ||||
|     private toastService: ToastService, | ||||
| @@ -81,8 +81,8 @@ export abstract class ManagementListComponent<T extends ObjectWithId> | ||||
|   public isLoading: boolean = false | ||||
|  | ||||
|   private nameFilterDebounce: Subject<string> | ||||
|   private unsubscribeNotifier: Subject<any> = new Subject() | ||||
|   private _nameFilter: string | ||||
|   protected unsubscribeNotifier: Subject<any> = new Subject() | ||||
|   protected _nameFilter: string | ||||
|  | ||||
|   public selectedObjects: Set<number> = new Set() | ||||
|   public togggleAll: boolean = false | ||||
|   | ||||
| @@ -17,9 +17,10 @@ export abstract class AbstractNameFilterService< | ||||
|     sortField?: string, | ||||
|     sortReverse?: boolean, | ||||
|     nameFilter?: string, | ||||
|     fullPerms?: boolean | ||||
|     fullPerms?: boolean, | ||||
|     extraParams?: { [key: string]: any } | ||||
|   ) { | ||||
|     let params = {} | ||||
|     let params = extraParams ?? {} | ||||
|     if (nameFilter) { | ||||
|       params['name__icontains'] = nameFilter | ||||
|     } | ||||
|   | ||||
| @@ -291,7 +291,7 @@ class OwnedObjectSerializer( | ||||
|  | ||||
|  | ||||
| class CorrespondentSerializer(MatchingModelSerializer, OwnedObjectSerializer): | ||||
|     last_correspondence = serializers.DateTimeField(read_only=True) | ||||
|     last_correspondence = serializers.DateTimeField(read_only=True, required=False) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Correspondent | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| import datetime | ||||
| import json | ||||
| from unittest import mock | ||||
|  | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.contrib.auth.models import User | ||||
| from django.utils import timezone | ||||
| from rest_framework import status | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| @@ -89,6 +91,57 @@ class TestApiObjects(DirectoriesMixin, APITestCase): | ||||
|         results = response.data["results"] | ||||
|         self.assertEqual(len(results), 2) | ||||
|  | ||||
|     def test_correspondent_last_correspondence(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Correspondent with documents | ||||
|         WHEN: | ||||
|             - API is called | ||||
|         THEN: | ||||
|             - Last correspondence date is returned only if requested for list, and for detail | ||||
|         """ | ||||
|  | ||||
|         Document.objects.create( | ||||
|             mime_type="application/pdf", | ||||
|             correspondent=self.c1, | ||||
|             created=timezone.make_aware(datetime.datetime(2022, 1, 1)), | ||||
|             checksum="123", | ||||
|         ) | ||||
|         Document.objects.create( | ||||
|             mime_type="application/pdf", | ||||
|             correspondent=self.c1, | ||||
|             created=timezone.make_aware(datetime.datetime(2022, 1, 2)), | ||||
|             checksum="456", | ||||
|         ) | ||||
|  | ||||
|         # Only if requested for list | ||||
|         response = self.client.get( | ||||
|             "/api/correspondents/", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         results = response.data["results"] | ||||
|         self.assertNotIn("last_correspondence", results[0]) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             "/api/correspondents/?last_correspondence=true", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         results = response.data["results"] | ||||
|         self.assertIn( | ||||
|             "2022-01-02", | ||||
|             results[0]["last_correspondence"], | ||||
|         ) | ||||
|  | ||||
|         # Included in detail by default | ||||
|         response = self.client.get( | ||||
|             f"/api/correspondents/{self.c1.id}/", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         self.assertIn( | ||||
|             "2022-01-02", | ||||
|             response.data["last_correspondence"], | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class TestApiStoragePaths(DirectoriesMixin, APITestCase): | ||||
|     ENDPOINT = "/api/storage_paths/" | ||||
|   | ||||
| @@ -253,14 +253,7 @@ class PermissionsAwareDocumentCountMixin(PassUserMixin): | ||||
| class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): | ||||
|     model = Correspondent | ||||
|  | ||||
|     queryset = ( | ||||
|         Correspondent.objects.prefetch_related("documents") | ||||
|         .annotate( | ||||
|             last_correspondence=Max("documents__created"), | ||||
|         ) | ||||
|         .select_related("owner") | ||||
|         .order_by(Lower("name")) | ||||
|     ) | ||||
|     queryset = Correspondent.objects.select_related("owner").order_by(Lower("name")) | ||||
|  | ||||
|     serializer_class = CorrespondentSerializer | ||||
|     pagination_class = StandardPagination | ||||
| @@ -279,6 +272,19 @@ class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): | ||||
|         "last_correspondence", | ||||
|     ) | ||||
|  | ||||
|     def list(self, request, *args, **kwargs): | ||||
|         if request.query_params.get("last_correspondence", None): | ||||
|             self.queryset = self.queryset.annotate( | ||||
|                 last_correspondence=Max("documents__created"), | ||||
|             ) | ||||
|         return super().list(request, *args, **kwargs) | ||||
|  | ||||
|     def retrieve(self, request, *args, **kwargs): | ||||
|         self.queryset = self.queryset.annotate( | ||||
|             last_correspondence=Max("documents__created"), | ||||
|         ) | ||||
|         return super().retrieve(request, *args, **kwargs) | ||||
|  | ||||
|  | ||||
| class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): | ||||
|     model = Tag | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon