mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge pull request #2910 from paperless-ngx/feature-improved-statistics-widget
Feature: Improved statistics widget
This commit is contained in:
		
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 890 KiB | 
| @@ -2202,20 +2202,48 @@ | ||||
|           <context context-type="linenumber">1</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1171694977288479084" datatype="html"> | ||||
|         <source>Documents in inbox: <x id="INTERPOLATION" equiv-text="{{statistics?.documents_inbox}}"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
|           <context context-type="linenumber">3</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4207135462646354574" datatype="html"> | ||||
|         <source>Total documents: <x id="INTERPOLATION" equiv-text="{{statistics?.documents_total}}"/></source> | ||||
|       <trans-unit id="2028517964701399614" datatype="html"> | ||||
|         <source>Go to inbox</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
|           <context context-type="linenumber">4</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3497361602348932709" datatype="html"> | ||||
|         <source>Documents in inbox</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
|           <context context-type="linenumber">5</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8809281703097241399" datatype="html"> | ||||
|         <source>Go to documents</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
|           <context context-type="linenumber">8</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3823413855067727192" datatype="html"> | ||||
|         <source>Total documents</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
|           <context context-type="linenumber">9</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6503529145162789855" datatype="html"> | ||||
|         <source>Total characters</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
|           <context context-type="linenumber">13</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8693603235657020323" datatype="html"> | ||||
|         <source>Other</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.ts</context> | ||||
|           <context context-type="linenumber">87</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8187573012244728580" datatype="html"> | ||||
|         <source>Upload new documents</source> | ||||
|         <context-group purpose="location"> | ||||
|   | ||||
							
								
								
									
										6
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								src-ui/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -22,6 +22,7 @@ | ||||
|         "@popperjs/core": "^2.11.6", | ||||
|         "bootstrap": "^5.2.3", | ||||
|         "file-saver": "^2.0.5", | ||||
|         "mime-names": "^1.0.0", | ||||
|         "ng2-pdf-viewer": "^9.1.2", | ||||
|         "ngx-color": "^8.0.3", | ||||
|         "ngx-cookie-service": "^15.0.0", | ||||
| @@ -13766,6 +13767,11 @@ | ||||
|         "node": ">= 0.6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/mime-names": { | ||||
|       "version": "1.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/mime-names/-/mime-names-1.0.0.tgz", | ||||
|       "integrity": "sha512-vLNEfYU63fz34panv/L3Lh3eW3+v0BlOB+bSGFdntv/gBNnokCbSsaNuHR9vH/NS5oWbL0HqMQf/3we4fRJyIQ==" | ||||
|     }, | ||||
|     "node_modules/mime-types": { | ||||
|       "version": "2.1.35", | ||||
|       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", | ||||
|   | ||||
| @@ -27,6 +27,7 @@ | ||||
|     "@popperjs/core": "^2.11.6", | ||||
|     "bootstrap": "^5.2.3", | ||||
|     "file-saver": "^2.0.5", | ||||
|     "mime-names": "^1.0.0", | ||||
|     "ng2-pdf-viewer": "^9.1.2", | ||||
|     "ngx-color": "^8.0.3", | ||||
|     "ngx-cookie-service": "^15.0.0", | ||||
|   | ||||
| @@ -1,6 +1,46 @@ | ||||
| <app-widget-frame title="Statistics" [loading]="loading" i18n-title> | ||||
|   <ng-container content> | ||||
|     <p class="card-text" i18n *ngIf="statistics?.documents_inbox !== null">Documents in inbox: {{statistics?.documents_inbox}}</p> | ||||
|     <p class="card-text" i18n>Total documents: {{statistics?.documents_total}}</p> | ||||
|     <div class="list-group border-light"> | ||||
|       <a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" title="Go to inbox" i18n-title href="javascript:void()" *ngIf="statistics?.documents_inbox !== null" (click)="goToInbox()"> | ||||
|         <ng-container i18n>Documents in inbox</ng-container>: | ||||
|         <span class="badge rounded-pill" [class.bg-primary]="statistics?.documents_inbox > 0" [class.bg-muted]="statistics?.documents_inbox === 0">{{statistics?.documents_inbox}}</span> | ||||
|       </a> | ||||
|       <a class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" title="Go to documents" i18n-title routerLink="/documents/"> | ||||
|         <ng-container i18n>Total documents</ng-container>: | ||||
|         <span class="badge bg-primary rounded-pill">{{statistics?.documents_total}}</span> | ||||
|       </a> | ||||
|       <div class="list-group-item d-flex justify-content-between align-items-center" routerLink="/documents/"> | ||||
|         <ng-container i18n>Total characters</ng-container>: | ||||
|         <span class="badge bg-secondary text-light rounded-pill">{{statistics?.character_count | number}}</span> | ||||
|       </div> | ||||
|       <div *ngIf="statistics?.document_file_type_counts?.length > 1" class="list-group-item filetypes"> | ||||
|         <div class="d-flex justify-content-between align-items-center my-2"> | ||||
|           <div class="progress flex-grow-1"> | ||||
|             <div *ngFor="let filetype of statistics?.document_file_type_counts; let i = index; let last = last" | ||||
|               class="progress-bar bg-primary text-primary-contrast" | ||||
|               role="progressbar" | ||||
|               [ngbPopover]="getFileTypeName(filetype)" | ||||
|               i18n-ngbPopover | ||||
|               triggers="mouseenter:mouseleave" | ||||
|               [attr.aria-label]="getFileTypeName(filetype)" | ||||
|               [class.me-1px]="!last" | ||||
|               [style.width]="getFileTypePercent(filetype) + '%'" | ||||
|               [style.opacity]="getItemOpacity(i)" | ||||
|               [attr.aria-valuenow]="getFileTypePercent(filetype)" | ||||
|               aria-valuemin="0" | ||||
|               aria-valuemax="100"> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="d-flex flex-wrap align-items-start"> | ||||
|           <div class="d-flex" *ngFor="let filetype of statistics?.document_file_type_counts; let i = index"> | ||||
|             <div class="text-nowrap me-2"> | ||||
|               <span class="badge rounded-pill bg-primary d-inline-block p-0 me-1" [style.opacity]="getItemOpacity(i)"></span> | ||||
|               <small class="text-nowrap"><span class="fw-bold">{{ getFileTypeExtension(filetype) }}</span> <span class="text-muted">({{getFileTypePercent(filetype) | number: '1.0-1'}}%)</span></small> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </ng-container> | ||||
| </app-widget-frame> | ||||
|   | ||||
| @@ -0,0 +1,10 @@ | ||||
| .filetypes { | ||||
|     .progress { | ||||
|         height: 0.6rem; | ||||
|     } | ||||
|  | ||||
|     .badge { | ||||
|         height: 0.6rem; | ||||
|         width: 0.6rem; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,23 @@ | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| import { Component, OnDestroy, OnInit } from '@angular/core' | ||||
| import { Observable, Subscription } from 'rxjs' | ||||
| import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' | ||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service' | ||||
| import { DocumentListViewService } from 'src/app/services/document-list-view.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import * as mimeTypeNames from 'mime-names' | ||||
|  | ||||
| export interface Statistics { | ||||
|   documents_total?: number | ||||
|   documents_inbox?: number | ||||
|   inbox_tag?: number | ||||
|   document_file_type_counts?: DocumentFileType[] | ||||
|   character_count?: number | ||||
| } | ||||
|  | ||||
| interface DocumentFileType { | ||||
|   mime_type: string | ||||
|   mime_type_count: number | ||||
| } | ||||
|  | ||||
| @Component({ | ||||
| @@ -19,7 +30,8 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   constructor( | ||||
|     private http: HttpClient, | ||||
|     private consumerStatusService: ConsumerStatusService | ||||
|     private consumerStatusService: ConsumerStatusService, | ||||
|     private documentListViewService: DocumentListViewService | ||||
|   ) {} | ||||
|  | ||||
|   statistics: Statistics = {} | ||||
| @@ -34,10 +46,43 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy { | ||||
|     this.loading = true | ||||
|     this.getStatistics().subscribe((statistics) => { | ||||
|       this.loading = false | ||||
|       const fileTypeMax = 5 | ||||
|       if (statistics.document_file_type_counts?.length > fileTypeMax) { | ||||
|         const others = statistics.document_file_type_counts.slice(fileTypeMax) | ||||
|         statistics.document_file_type_counts = | ||||
|           statistics.document_file_type_counts.slice(0, fileTypeMax) | ||||
|         statistics.document_file_type_counts.push({ | ||||
|           mime_type: $localize`Other`, | ||||
|           mime_type_count: others.reduce( | ||||
|             (currentValue, documentFileType) => | ||||
|               documentFileType.mime_type_count + currentValue, | ||||
|             0 | ||||
|           ), | ||||
|         }) | ||||
|       } | ||||
|       this.statistics = statistics | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   getFileTypeExtension(filetype: DocumentFileType): string { | ||||
|     return ( | ||||
|       mimeTypeNames[filetype.mime_type]?.extensions[0]?.toUpperCase() ?? | ||||
|       filetype.mime_type | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   getFileTypeName(filetype: DocumentFileType): string { | ||||
|     return mimeTypeNames[filetype.mime_type]?.name ?? filetype.mime_type | ||||
|   } | ||||
|  | ||||
|   getFileTypePercent(filetype: DocumentFileType): number { | ||||
|     return (filetype.mime_type_count / this.statistics?.documents_total) * 100 | ||||
|   } | ||||
|  | ||||
|   getItemOpacity(i: number): number { | ||||
|     return 1 - i / this.statistics?.document_file_type_counts.length | ||||
|   } | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.reload() | ||||
|     this.subscription = this.consumerStatusService | ||||
| @@ -50,4 +95,13 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy { | ||||
|   ngOnDestroy(): void { | ||||
|     this.subscription.unsubscribe() | ||||
|   } | ||||
|  | ||||
|   goToInbox() { | ||||
|     this.documentListViewService.quickFilter([ | ||||
|       { | ||||
|         rule_type: FILTER_HAS_TAGS_ALL, | ||||
|         value: this.statistics.inbox_tag.toString(), | ||||
|       }, | ||||
|     ]) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -629,3 +629,7 @@ code { | ||||
| .accordion-button::after { | ||||
|   filter: invert(0.5) saturate(0); | ||||
| } | ||||
|  | ||||
| .me-1px { | ||||
|   margin-right: 1px !important; | ||||
| } | ||||
|   | ||||
| @@ -231,6 +231,14 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt | ||||
|   .dropdown-menu { | ||||
|     --bs-dropdown-color: var(--bs-body-color); | ||||
|   } | ||||
|  | ||||
|   .card .list-group-item { | ||||
|     --bs-border-color: rgb(var(--bs-dark-rgb)); | ||||
|  | ||||
|     .bg-secondary { | ||||
|       background-color: rgb(var(--bs-dark-rgb)) !important; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| body.color-scheme-dark { | ||||
|   | ||||
| @@ -1039,9 +1039,24 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|  | ||||
|     def test_statistics(self): | ||||
|  | ||||
|         doc1 = Document.objects.create(title="none1", checksum="A") | ||||
|         doc2 = Document.objects.create(title="none2", checksum="B") | ||||
|         doc3 = Document.objects.create(title="none3", checksum="C") | ||||
|         doc1 = Document.objects.create( | ||||
|             title="none1", | ||||
|             checksum="A", | ||||
|             mime_type="application/pdf", | ||||
|             content="abc", | ||||
|         ) | ||||
|         doc2 = Document.objects.create( | ||||
|             title="none2", | ||||
|             checksum="B", | ||||
|             mime_type="application/pdf", | ||||
|             content="123", | ||||
|         ) | ||||
|         doc3 = Document.objects.create( | ||||
|             title="none3", | ||||
|             checksum="C", | ||||
|             mime_type="text/plain", | ||||
|             content="hello", | ||||
|         ) | ||||
|  | ||||
|         tag_inbox = Tag.objects.create(name="t1", is_inbox_tag=True) | ||||
|  | ||||
| @@ -1051,6 +1066,16 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         self.assertEqual(response.data["documents_total"], 3) | ||||
|         self.assertEqual(response.data["documents_inbox"], 1) | ||||
|         self.assertEqual(response.data["inbox_tag"], tag_inbox.pk) | ||||
|         self.assertEqual( | ||||
|             response.data["document_file_type_counts"][0]["mime_type_count"], | ||||
|             2, | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             response.data["document_file_type_counts"][1]["mime_type_count"], | ||||
|             1, | ||||
|         ) | ||||
|         self.assertEqual(response.data["character_count"], 11) | ||||
|  | ||||
|     def test_statistics_no_inbox_tag(self): | ||||
|         Document.objects.create(title="none1", checksum="A") | ||||
| @@ -1058,6 +1083,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | ||||
|         response = self.client.get("/api/statistics/") | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         self.assertEqual(response.data["documents_inbox"], None) | ||||
|         self.assertEqual(response.data["inbox_tag"], None) | ||||
|  | ||||
|     @mock.patch("documents.views.consume_file.delay") | ||||
|     def test_upload(self, m): | ||||
|   | ||||
| @@ -20,7 +20,9 @@ from django.db.models import Case | ||||
| from django.db.models import Count | ||||
| from django.db.models import IntegerField | ||||
| from django.db.models import Max | ||||
| from django.db.models import Sum | ||||
| from django.db.models import When | ||||
| from django.db.models.functions import Length | ||||
| from django.db.models.functions import Lower | ||||
| from django.http import Http404 | ||||
| from django.http import HttpResponse | ||||
| @@ -186,6 +188,7 @@ class TagViewSet(ModelViewSet, PassUserMixin): | ||||
|     ) | ||||
|  | ||||
|     def get_serializer_class(self, *args, **kwargs): | ||||
|         print(self.request.version) | ||||
|         if int(self.request.version) == 1: | ||||
|             return TagSerializerVersion1 | ||||
|         else: | ||||
| @@ -795,17 +798,38 @@ class StatisticsView(APIView): | ||||
|  | ||||
|     def get(self, request, format=None): | ||||
|         documents_total = Document.objects.all().count() | ||||
|         if Tag.objects.filter(is_inbox_tag=True).exists(): | ||||
|  | ||||
|         inbox_tag = Tag.objects.filter(is_inbox_tag=True) | ||||
|  | ||||
|         documents_inbox = ( | ||||
|             Document.objects.filter(tags__is_inbox_tag=True).distinct().count() | ||||
|             if inbox_tag.exists() | ||||
|             else None | ||||
|         ) | ||||
|  | ||||
|         document_file_type_counts = ( | ||||
|             Document.objects.values("mime_type") | ||||
|             .annotate(mime_type_count=Count("mime_type")) | ||||
|             .order_by("-mime_type_count") | ||||
|             if documents_total > 0 | ||||
|             else 0 | ||||
|         ) | ||||
|  | ||||
|         character_count = ( | ||||
|             Document.objects.annotate( | ||||
|                 characters=Length("content"), | ||||
|             ) | ||||
|             .aggregate(Sum("characters")) | ||||
|             .get("characters__sum") | ||||
|         ) | ||||
|         else: | ||||
|             documents_inbox = None | ||||
|  | ||||
|         return Response( | ||||
|             { | ||||
|                 "documents_total": documents_total, | ||||
|                 "documents_inbox": documents_inbox, | ||||
|                 "inbox_tag": inbox_tag.first().pk if inbox_tag.exists() else None, | ||||
|                 "document_file_type_counts": document_file_type_counts, | ||||
|                 "character_count": character_count, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon