mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-30 19:45:44 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5d3247ca7 | ||
|
|
8a67481a9d | ||
|
|
652f964f5d | ||
|
|
80a1827fa0 |
No files matched your search
+5
-5
@@ -10334,18 +10334,18 @@
|
||||
<context context-type="linenumber">294,295</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">328</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3420321797707163677" datatype="html">
|
||||
<source>Preview document</source>
|
||||
<trans-unit id="2179847500064178686" datatype="html">
|
||||
<source>Edit document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
<context context-type="linenumber">329</context>
|
||||
<context context-type="linenumber">331</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512084577073831437" datatype="html">
|
||||
|
||||
-7
@@ -1,10 +1,3 @@
|
||||
.custom-fields-dropdown {
|
||||
min-width: 300px;
|
||||
|
||||
// correct position on mobile
|
||||
@media (max-width: 575.98px) {
|
||||
&.show {
|
||||
margin-left: -245px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,7 +308,7 @@
|
||||
</tr>
|
||||
<tr class="align-middle">
|
||||
<td i18n>Original SHA256 checksum</td>
|
||||
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||
<td><span class="badge bg-light text-dark font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td i18n>Original file size</td>
|
||||
@@ -465,7 +465,7 @@
|
||||
@switch (archiveContentRenderType) {
|
||||
@case (ContentRenderType.PDF) {
|
||||
@if (!useNativePdfViewer) {
|
||||
<div class="preview-sticky pdf-viewer-container">
|
||||
<div class="preview-sticky pdf-viewer-container" tabindex="-1">
|
||||
<pngx-pdf-viewer
|
||||
[src]="pdfSource()"
|
||||
[sourceRevision]="previewRevision()"
|
||||
@@ -477,7 +477,7 @@
|
||||
(loadError)="onError($event)"
|
||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
||||
</pngx-pdf-viewer>
|
||||
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||
<div class="preview-controls position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||
@if (previewNumPages()) {
|
||||
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
|
||||
|
||||
@@ -12,6 +12,22 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover .preview-controls,
|
||||
&:focus-within .preview-controls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group .dropdown-toggle-split {
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
i18n>Correspondent</th>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<th class="cursor-pointer"
|
||||
<th class="cursor-pointer ps-5"
|
||||
pngxSortable="title"
|
||||
title="Sort by title" i18n-title
|
||||
[currentSortField]="list.sortField"
|
||||
@@ -322,13 +322,13 @@
|
||||
</td>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
<td width="30%">
|
||||
<td width="30%" class="position-relative" [class.ps-5]="activeDisplayFields.includes(DisplayField.TITLE)">
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||
<pngx-preview-popup class="position-absolute start-0" [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||
<i-bs name="eye"></i-bs>
|
||||
</pngx-preview-popup>
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||
</div>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
|
||||
@@ -87,6 +87,10 @@ $paperless-card-breakpoints: (
|
||||
}
|
||||
}
|
||||
|
||||
.table .ps-5 {
|
||||
padding-left: 2.2rem !important;
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -576,9 +576,12 @@ def annotate_document_count_by_ids(
|
||||
or CustomFieldInstance)
|
||||
related_object_field: field on the relation pointing back to queryset pk
|
||||
document_ids: the document ids to count against -- a concrete list/set,
|
||||
or a simple (already resolved) queryset of ids. Keep
|
||||
the list small: it is embedded in the statement as
|
||||
literal parameters.
|
||||
or a simple (already resolved) queryset of ids. Callers
|
||||
that need this filtered by a complex condition (e.g. a
|
||||
permission check) should resolve it to a concrete list
|
||||
first if the same ids will be reused across multiple
|
||||
calls, rather than passing the complex queryset itself
|
||||
into each -- see ``_get_selection_data_for_queryset``.
|
||||
target_field: field on the relation pointing to Document id
|
||||
"""
|
||||
|
||||
|
||||
+63
-53
@@ -168,6 +168,7 @@ from documents.permissions import PaperlessNotePermissions
|
||||
from documents.permissions import PaperlessObjectPermissions
|
||||
from documents.permissions import TrashPermissions
|
||||
from documents.permissions import ViewDocumentsPermissions
|
||||
from documents.permissions import annotate_document_count_by_ids
|
||||
from documents.permissions import annotate_document_count_for_related_queryset
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_permission_grantees
|
||||
@@ -1022,61 +1023,70 @@ class DocumentViewSet(
|
||||
)
|
||||
|
||||
def _get_selection_data_for_queryset(self, queryset):
|
||||
# The filtered queryset (user filters plus the permission filter) is
|
||||
# kept as SQL and handed to the database, never resolved to a Python id
|
||||
# list: at hundreds of thousands of documents that list becomes a
|
||||
# multi-MB IN (...) clause per statement, which is slow to plan and, on
|
||||
# SQLite, exceeds the bound-parameter limit outright. Each relation
|
||||
# instead gets one GROUP BY with the filter as a plain WHERE
|
||||
# (foreign keys) or a semi-join subquery (link tables).
|
||||
# order_by() drops the default/user ordering, which is irrelevant for
|
||||
# counting and would otherwise force a sort.
|
||||
documents = queryset.order_by()
|
||||
document_ids = documents.values("pk")
|
||||
|
||||
def foreign_key_counts(field_name: str) -> dict[int, int]:
|
||||
# distinct: filters can join (and so repeat) a document row.
|
||||
return dict(
|
||||
documents.filter(**{f"{field_name}__isnull": False})
|
||||
.values_list(field_name)
|
||||
.annotate(document_count=Count("pk", distinct=True)),
|
||||
)
|
||||
|
||||
def link_table_counts(link_model, field_name: str) -> dict[int, int]:
|
||||
return dict(
|
||||
link_model.objects.filter(document_id__in=document_ids)
|
||||
.values_list(field_name)
|
||||
.annotate(document_count=Count("document_id")),
|
||||
)
|
||||
|
||||
def selection(model, counts: dict[int, int]) -> list[dict[str, int]]:
|
||||
# Every object is listed, with 0 for those with no matching document.
|
||||
return [
|
||||
{"id": pk, "document_count": counts.get(pk, 0)}
|
||||
for pk in model.objects.values_list("pk", flat=True)
|
||||
]
|
||||
# Resolve once instead of once per model below. `queryset` can carry an
|
||||
# arbitrarily expensive WHERE clause (user filters plus the permission
|
||||
# filter); re-embedding it as a subquery inside 5 separate Count(...)
|
||||
# calls forces the database to re-evaluate that whole thing 5 times, and
|
||||
# -- for FK relations especially -- can defeat semi-join planning
|
||||
# entirely at scale. A concrete id list is cheap to reuse.
|
||||
# order_by() drops the default/user ordering -- irrelevant for a plain
|
||||
# id list, but left in place it forces a sort over the full filtered
|
||||
# set before the ids can even be collected.
|
||||
document_ids = list(queryset.order_by().values_list("pk", flat=True))
|
||||
|
||||
correspondents = Correspondent.objects.annotate(
|
||||
document_count=Count(
|
||||
"documents",
|
||||
filter=Q(documents__id__in=document_ids),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
document_types = DocumentType.objects.annotate(
|
||||
document_count=Count(
|
||||
"documents",
|
||||
filter=Q(documents__id__in=document_ids),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
storage_paths = StoragePath.objects.annotate(
|
||||
document_count=Count(
|
||||
"documents",
|
||||
filter=Q(documents__id__in=document_ids),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
# Tag and CustomField reach Document through an M2M/through-model table;
|
||||
# a plain Count(filter=...) there is a much more expensive plan than the
|
||||
# FK relations above once the bridge table is large -- see
|
||||
# annotate_document_count_by_ids() for why.
|
||||
tags = annotate_document_count_by_ids(
|
||||
Tag.objects.all(),
|
||||
through_model=Document.tags.through,
|
||||
related_object_field="tag_id",
|
||||
document_ids=document_ids,
|
||||
)
|
||||
custom_fields = annotate_document_count_by_ids(
|
||||
CustomField.objects.all(),
|
||||
through_model=CustomFieldInstance,
|
||||
related_object_field="field_id",
|
||||
document_ids=document_ids,
|
||||
)
|
||||
return {
|
||||
"selected_correspondents": selection(
|
||||
Correspondent,
|
||||
foreign_key_counts("correspondent_id"),
|
||||
),
|
||||
"selected_tags": selection(
|
||||
Tag,
|
||||
link_table_counts(Document.tags.through, "tag_id"),
|
||||
),
|
||||
"selected_document_types": selection(
|
||||
DocumentType,
|
||||
foreign_key_counts("document_type_id"),
|
||||
),
|
||||
"selected_storage_paths": selection(
|
||||
StoragePath,
|
||||
foreign_key_counts("storage_path_id"),
|
||||
),
|
||||
"selected_custom_fields": selection(
|
||||
CustomField,
|
||||
link_table_counts(CustomFieldInstance, "field_id"),
|
||||
),
|
||||
"selected_correspondents": [
|
||||
{"id": t.id, "document_count": t.document_count} for t in correspondents
|
||||
],
|
||||
"selected_tags": [
|
||||
{"id": t.id, "document_count": t.document_count} for t in tags
|
||||
],
|
||||
"selected_document_types": [
|
||||
{"id": t.id, "document_count": t.document_count} for t in document_types
|
||||
],
|
||||
"selected_storage_paths": [
|
||||
{"id": t.id, "document_count": t.document_count} for t in storage_paths
|
||||
],
|
||||
"selected_custom_fields": [
|
||||
{"id": t.id, "document_count": t.document_count} for t in custom_fields
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
Reference in new issue
Block a user