mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-09 09:58:20 -05:00
Merge pull request #2910 from paperless-ngx/feature-improved-statistics-widget
Feature: Improved statistics widget
This commit is contained in:
commit
6a34a35585
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 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="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">
|
<trans-unit id="8187573012244728580" datatype="html">
|
||||||
<source>Upload new documents</source>
|
<source>Upload new documents</source>
|
||||||
<context-group purpose="location">
|
<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",
|
"@popperjs/core": "^2.11.6",
|
||||||
"bootstrap": "^5.2.3",
|
"bootstrap": "^5.2.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"mime-names": "^1.0.0",
|
||||||
"ng2-pdf-viewer": "^9.1.2",
|
"ng2-pdf-viewer": "^9.1.2",
|
||||||
"ngx-color": "^8.0.3",
|
"ngx-color": "^8.0.3",
|
||||||
"ngx-cookie-service": "^15.0.0",
|
"ngx-cookie-service": "^15.0.0",
|
||||||
@ -13766,6 +13767,11 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/mime-types": {
|
||||||
"version": "2.1.35",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
@ -27,6 +27,7 @@
|
|||||||
"@popperjs/core": "^2.11.6",
|
"@popperjs/core": "^2.11.6",
|
||||||
"bootstrap": "^5.2.3",
|
"bootstrap": "^5.2.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"mime-names": "^1.0.0",
|
||||||
"ng2-pdf-viewer": "^9.1.2",
|
"ng2-pdf-viewer": "^9.1.2",
|
||||||
"ngx-color": "^8.0.3",
|
"ngx-color": "^8.0.3",
|
||||||
"ngx-cookie-service": "^15.0.0",
|
"ngx-cookie-service": "^15.0.0",
|
||||||
|
@ -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 *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>
|
</ng-container>
|
||||||
</app-widget-frame>
|
</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 { 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 } 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'
|
||||||
|
import * as mimeTypeNames from 'mime-names'
|
||||||
|
|
||||||
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 +30,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 = {}
|
||||||
@ -34,10 +46,43 @@ export class StatisticsWidgetComponent implements OnInit, OnDestroy {
|
|||||||
this.loading = true
|
this.loading = true
|
||||||
this.getStatistics().subscribe((statistics) => {
|
this.getStatistics().subscribe((statistics) => {
|
||||||
this.loading = false
|
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
|
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 {
|
ngOnInit(): void {
|
||||||
this.reload()
|
this.reload()
|
||||||
this.subscription = this.consumerStatusService
|
this.subscription = this.consumerStatusService
|
||||||
@ -50,4 +95,13 @@ 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(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -629,3 +629,7 @@ code {
|
|||||||
.accordion-button::after {
|
.accordion-button::after {
|
||||||
filter: invert(0.5) saturate(0);
|
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 {
|
.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):
|
||||||
|
@ -20,7 +20,9 @@ from django.db.models import Case
|
|||||||
from django.db.models import Count
|
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 Sum
|
||||||
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 +188,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:
|
||||||
@ -795,17 +798,38 @@ 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 = (
|
||||||
|
Document.objects.annotate(
|
||||||
|
characters=Length("content"),
|
||||||
)
|
)
|
||||||
else:
|
.aggregate(Sum("characters"))
|
||||||
documents_inbox = None
|
.get("characters__sum")
|
||||||
|
)
|
||||||
|
|
||||||
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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user