mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-28 03:46:06 -05:00 
			
		
		
		
	Improved statistics widget
This commit is contained in:
		| @@ -1826,7 +1826,7 @@ | |||||||
|         <source>Not assigned</source> |         <source>Not assigned</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context> |           <context context-type="sourcefile">src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts</context> | ||||||
|           <context context-type="linenumber">321</context> |           <context context-type="linenumber">335</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note> |         <note priority="1" from="description">Filter drop down element to filter for documents with no correspondent/type/tag assigned</note> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
| @@ -2202,20 +2202,48 @@ | |||||||
|           <context context-type="linenumber">1</context> |           <context context-type="linenumber">1</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1171694977288479084" datatype="html"> |       <trans-unit id="2028517964701399614" datatype="html"> | ||||||
|         <source>Documents in inbox: <x id="INTERPOLATION" equiv-text="{{statistics?.documents_inbox}}"/></source> |         <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">3</context> |  | ||||||
|         </context-group> |  | ||||||
|       </trans-unit> |  | ||||||
|       <trans-unit id="4207135462646354574" datatype="html"> |  | ||||||
|         <source>Total documents: <x id="INTERPOLATION" equiv-text="{{statistics?.documents_total}}"/></source> |  | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> |           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||||
|           <context context-type="linenumber">4</context> |           <context context-type="linenumber">4</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </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="6125391559813574136" datatype="html"> | ||||||
|  |         <source>File types</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">17</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|       <trans-unit id="8187573012244728580" datatype="html"> |       <trans-unit id="8187573012244728580" datatype="html"> | ||||||
|         <source>Upload new documents</source> |         <source>Upload new documents</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|   | |||||||
| @@ -1,6 +1,46 @@ | |||||||
| <app-widget-frame title="Statistics" [loading]="loading" i18n-title> | <app-widget-frame title="Statistics" [loading]="loading" i18n-title> | ||||||
|   <ng-container content> |   <ng-container content> | ||||||
|     <p class="card-text" i18n *ngIf="statistics?.documents_inbox !== null">Documents in inbox: {{statistics?.documents_inbox}}</p> |     <div class="list-group border-light"> | ||||||
|     <p class="card-text" i18n>Total documents: {{statistics?.documents_total}}</p> |       <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 class="list-group-item d-flex justify-content-between align-items-center"> | ||||||
|  |         <div class="flex-grow-1"><ng-container i18n>File types</ng-container>:</div> | ||||||
|  |         <div class="d-flex flex-column flex-grow-1"> | ||||||
|  |           <div *ngFor="let filetype of statistics?.document_file_type_counts; let i = index" class="d-flex justify-content-between align-items-center"> | ||||||
|  |             <span class="fst-italic text-muted">{{filetype.mime_type}}</span> | ||||||
|  |             <span class="badge bg-secondary text-light rounded-pill">{{getFileTypePercent(filetype) | number: '1.0-1'}}%</span> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <!-- <div class="list-group-item border-dark d-flex justify-content-between align-items-center"> | ||||||
|  |         <span class="me-3" i18n>File types:</span> | ||||||
|  |         <div class="progress flex-grow-1"> | ||||||
|  |           <div *ngFor="let filetype of statistics?.document_file_type_counts; let i = index" | ||||||
|  |             class="progress-bar bg-primary text-primary-contrast" | ||||||
|  |             [class.me-1]="i < statistics?.document_file_type_counts.length - 1" | ||||||
|  |             role="progressbar" | ||||||
|  |             [ngbPopover]="filetype.mime_type" | ||||||
|  |             i18n-ngbPopover | ||||||
|  |             triggers="mouseenter:mouseleave" | ||||||
|  |             [attr.aria-label]="filetype.mime_type" | ||||||
|  |             [style.width]="getFileTypePercent(filetype) + '%'" | ||||||
|  |             [attr.aria-valuenow]="getFileTypePercent(filetype)" | ||||||
|  |             aria-valuemin="0" | ||||||
|  |             aria-valuemax="100"> | ||||||
|  |             <ng-container *ngIf="getFileTypePercent(filetype) > 25">{{filetype.mime_type}}</ng-container> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> --> | ||||||
|  |     </div> | ||||||
|   </ng-container> |   </ng-container> | ||||||
| </app-widget-frame> | </app-widget-frame> | ||||||
|   | |||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | .flex-column { | ||||||
|  |     row-gap: 0.2rem; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,12 +1,25 @@ | |||||||
| import { HttpClient } from '@angular/common/http' | import { HttpClient } from '@angular/common/http' | ||||||
| import { Component, OnDestroy, OnInit } from '@angular/core' | import { Component, OnDestroy, OnInit } from '@angular/core' | ||||||
| import { Observable, Subscription } from 'rxjs' | import { Observable, Subscription } from 'rxjs' | ||||||
|  | import { | ||||||
|  |   FILTER_HAS_TAGS_ALL, | ||||||
|  |   FILTER_IS_IN_INBOX, | ||||||
|  | } from 'src/app/data/filter-rule-type' | ||||||
| import { ConsumerStatusService } from 'src/app/services/consumer-status.service' | 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 { environment } from 'src/environments/environment' | ||||||
|  |  | ||||||
| export interface Statistics { | export interface Statistics { | ||||||
|   documents_total?: number |   documents_total?: number | ||||||
|   documents_inbox?: 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({ | @Component({ | ||||||
| @@ -19,7 +32,8 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy { | |||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
|     private http: HttpClient, |     private http: HttpClient, | ||||||
|     private consumerStatusService: ConsumerStatusService |     private consumerStatusService: ConsumerStatusService, | ||||||
|  |     private documentListViewService: DocumentListViewService | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   statistics: Statistics = {} |   statistics: Statistics = {} | ||||||
| @@ -50,4 +64,17 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy { | |||||||
|   ngOnDestroy(): void { |   ngOnDestroy(): void { | ||||||
|     this.subscription.unsubscribe() |     this.subscription.unsubscribe() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   goToInbox() { | ||||||
|  |     this.documentListViewService.quickFilter([ | ||||||
|  |       { | ||||||
|  |         rule_type: FILTER_HAS_TAGS_ALL, | ||||||
|  |         value: this.statistics.inbox_tag.toString(), | ||||||
|  |       }, | ||||||
|  |     ]) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   getFileTypePercent(filetype: DocumentFileType): number { | ||||||
|  |     return (filetype.mime_type_count / this.statistics?.documents_total) * 100 | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -223,6 +223,14 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt | |||||||
|   .dropdown-menu { |   .dropdown-menu { | ||||||
|     --bs-dropdown-color: var(--bs-body-color); |     --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 { | body.color-scheme-dark { | ||||||
|   | |||||||
| @@ -1039,9 +1039,24 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | |||||||
|  |  | ||||||
|     def test_statistics(self): |     def test_statistics(self): | ||||||
|  |  | ||||||
|         doc1 = Document.objects.create(title="none1", checksum="A") |         doc1 = Document.objects.create( | ||||||
|         doc2 = Document.objects.create(title="none2", checksum="B") |             title="none1", | ||||||
|         doc3 = Document.objects.create(title="none3", checksum="C") |             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) |         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.status_code, status.HTTP_200_OK) | ||||||
|         self.assertEqual(response.data["documents_total"], 3) |         self.assertEqual(response.data["documents_total"], 3) | ||||||
|         self.assertEqual(response.data["documents_inbox"], 1) |         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): |     def test_statistics_no_inbox_tag(self): | ||||||
|         Document.objects.create(title="none1", checksum="A") |         Document.objects.create(title="none1", checksum="A") | ||||||
| @@ -1058,6 +1083,7 @@ class TestDocumentApi(DirectoriesMixin, APITestCase): | |||||||
|         response = self.client.get("/api/statistics/") |         response = self.client.get("/api/statistics/") | ||||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) |         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|         self.assertEqual(response.data["documents_inbox"], None) |         self.assertEqual(response.data["documents_inbox"], None) | ||||||
|  |         self.assertEqual(response.data["inbox_tag"], None) | ||||||
|  |  | ||||||
|     @mock.patch("documents.views.consume_file.delay") |     @mock.patch("documents.views.consume_file.delay") | ||||||
|     def test_upload(self, m): |     def test_upload(self, m): | ||||||
|   | |||||||
| @@ -21,6 +21,7 @@ from django.db.models import Count | |||||||
| from django.db.models import IntegerField | from django.db.models import IntegerField | ||||||
| from django.db.models import Max | from django.db.models import Max | ||||||
| from django.db.models import When | from django.db.models import When | ||||||
|  | from django.db.models.functions import Length | ||||||
| from django.db.models.functions import Lower | from django.db.models.functions import Lower | ||||||
| from django.http import Http404 | from django.http import Http404 | ||||||
| from django.http import HttpResponse | from django.http import HttpResponse | ||||||
| @@ -186,6 +187,7 @@ class TagViewSet(ModelViewSet, PassUserMixin): | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     def get_serializer_class(self, *args, **kwargs): |     def get_serializer_class(self, *args, **kwargs): | ||||||
|  |         print(self.request.version) | ||||||
|         if int(self.request.version) == 1: |         if int(self.request.version) == 1: | ||||||
|             return TagSerializerVersion1 |             return TagSerializerVersion1 | ||||||
|         else: |         else: | ||||||
| @@ -794,17 +796,41 @@ class StatisticsView(APIView): | |||||||
|  |  | ||||||
|     def get(self, request, format=None): |     def get(self, request, format=None): | ||||||
|         documents_total = Document.objects.all().count() |         documents_total = Document.objects.all().count() | ||||||
|         if Tag.objects.filter(is_inbox_tag=True).exists(): |  | ||||||
|             documents_inbox = ( |         inbox_tag = Tag.objects.filter(is_inbox_tag=True) | ||||||
|                 Document.objects.filter(tags__is_inbox_tag=True).distinct().count() |  | ||||||
|  |         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 = ( | ||||||
|  |             sum( | ||||||
|  |                 Document.objects.annotate(characters=Length("content")).values_list( | ||||||
|  |                     "characters", | ||||||
|  |                     flat=True, | ||||||
|  |                 ), | ||||||
|             ) |             ) | ||||||
|         else: |             if documents_total > 0 | ||||||
|             documents_inbox = None |             else 0 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         return Response( |         return Response( | ||||||
|             { |             { | ||||||
|                 "documents_total": documents_total, |                 "documents_total": documents_total, | ||||||
|                 "documents_inbox": documents_inbox, |                 "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