Compare commits

..

10 Commits

Author SHA1 Message Date
shamoon
f3e664b577 Fix caching
[ci skip]
2025-09-08 09:52:37 -07:00
shamoon
50a5192c89 Fix frontend versions switching
[ci skip]
2025-09-08 09:19:40 -07:00
shamoon
3a6bcec27c Update views.py 2025-09-08 09:12:10 -07:00
shamoon
6a58d3ed20 version aware doc endpoints 2025-09-08 08:52:17 -07:00
shamoon
7e788a0ecd Fix archive filename clash 2025-09-08 08:30:34 -07:00
shamoon
dac15ea6be Super basic UI stuff
[ci skip]
2025-09-08 08:13:55 -07:00
shamoon
67f9375d77 Bulk editing to update version instead of replace 2025-09-08 08:13:41 -07:00
shamoon
9e8b6071a3 Fix migration 2025-09-08 08:13:25 -07:00
shamoon
8897307a10 Basic start of update endpoint 2025-09-08 07:56:39 -07:00
shamoon
26757e4930 Add head_version 2025-09-08 07:55:25 -07:00
14 changed files with 519 additions and 92 deletions

View File

@@ -1,7 +1,30 @@
<pngx-page-header [(title)]="title"> <pngx-page-header [(title)]="title">
@if (document?.versions?.length > 0) {
<div class="btn-group" ngbDropdown role="group">
<div class="btn-group" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle>
<i-bs name="layers"></i-bs>
<span class="d-none d-lg-inline ps-1" i18n>Version</span>
</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
@for (vid of document.versions; track vid) {
<button ngbDropdownItem (click)="selectVersion(vid)">
<span i18n>Version</span> {{vid}}
@if (selectedVersionId === vid) { <span>&nbsp;</span> }
</button>
}
</div>
</div>
<input #versionFileInput type="file" class="visually-hidden" (change)="onVersionFileSelected($event)" />
<button class="btn btn-sm btn-outline-secondary" title="Upload new version" i18n-title (click)="triggerUploadVersion()" [disabled]="!userIsOwner || !userCanEdit">
<i-bs name="file-earmark-plus"></i-bs><span class="visually-hidden" i18n>Upload new version</span>
</button>
</div>
}
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) { @if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages) { @if (previewNumPages) {
<div class="input-group input-group-sm d-none d-md-flex"> <div class="input-group input-group-sm ms-2 d-none d-md-flex">
<div class="input-group-text" i18n>Page</div> <div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" /> <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div> <div class="input-group-text" i18n>of {{previewNumPages}}</div>

View File

@@ -220,6 +220,8 @@ export class DocumentDetailComponent
titleSubject: Subject<string> = new Subject() titleSubject: Subject<string> = new Subject()
previewUrl: string previewUrl: string
thumbUrl: string thumbUrl: string
// Versioning: which document ID to use for file preview/download
selectedVersionId: number
previewText: string previewText: string
previewLoaded: boolean = false previewLoaded: boolean = false
tiffURL: string tiffURL: string
@@ -268,6 +270,7 @@ export class DocumentDetailComponent
public readonly DataType = DataType public readonly DataType = DataType
@ViewChild('nav') nav: NgbNav @ViewChild('nav') nav: NgbNav
@ViewChild('versionFileInput') versionFileInput
@ViewChild('pdfPreview') set pdfPreview(element) { @ViewChild('pdfPreview') set pdfPreview(element) {
// this gets called when component added or removed from DOM // this gets called when component added or removed from DOM
if ( if (
@@ -396,7 +399,10 @@ export class DocumentDetailComponent
} }
private loadDocument(documentId: number): void { private loadDocument(documentId: number): void {
this.previewUrl = this.documentsService.getPreviewUrl(documentId) this.selectedVersionId = documentId
this.previewUrl = this.documentsService.getPreviewUrl(
this.selectedVersionId
)
this.http this.http
.get(this.previewUrl, { responseType: 'text' }) .get(this.previewUrl, { responseType: 'text' })
.pipe( .pipe(
@@ -411,7 +417,7 @@ export class DocumentDetailComponent
err.message ?? err.toString() err.message ?? err.toString()
}`), }`),
}) })
this.thumbUrl = this.documentsService.getThumbUrl(documentId) this.thumbUrl = this.documentsService.getThumbUrl(this.selectedVersionId)
this.documentsService this.documentsService
.get(documentId) .get(documentId)
.pipe( .pipe(
@@ -632,6 +638,10 @@ export class DocumentDetailComponent
updateComponent(doc: Document) { updateComponent(doc: Document) {
this.document = doc this.document = doc
// Default selected version is the newest version
this.selectedVersionId = doc.versions?.length
? Math.max(...doc.versions)
: doc.id
this.requiresPassword = false this.requiresPassword = false
this.updateFormForCustomFields() this.updateFormForCustomFields()
if (this.archiveContentRenderType === ContentRenderType.TIFF) { if (this.archiveContentRenderType === ContentRenderType.TIFF) {
@@ -696,6 +706,32 @@ export class DocumentDetailComponent
this.prepareForm(doc) this.prepareForm(doc)
} }
// Update file preview and download target to a specific version (by document id)
selectVersion(versionId: number) {
this.selectedVersionId = versionId
this.previewUrl = this.documentsService.getPreviewUrl(
this.documentId,
false,
this.selectedVersionId
)
this.thumbUrl = this.documentsService.getThumbUrl(this.selectedVersionId)
// For text previews, refresh content
this.http
.get(this.previewUrl, { responseType: 'text' })
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
takeUntil(this.docChangeNotifier)
)
.subscribe({
next: (res) => (this.previewText = res.toString()),
error: (err) =>
(this.previewText = $localize`An error occurred loading content: ${
err.message ?? err.toString()
}`),
})
}
get customFieldFormFields(): FormArray { get customFieldFormFields(): FormArray {
return this.documentForm.get('custom_fields') as FormArray return this.documentForm.get('custom_fields') as FormArray
} }
@@ -1043,10 +1079,41 @@ export class DocumentDetailComponent
}) })
} }
// Upload a new file version for this document
triggerUploadVersion() {
this.versionFileInput?.nativeElement?.click()
}
onVersionFileSelected(event: Event) {
const input = event.target as HTMLInputElement
if (!input?.files || input.files.length === 0) return
const file = input.files[0]
// Reset input to allow re-selection of the same file later
input.value = ''
this.documentsService
.uploadVersion(this.documentId, file)
.pipe(first())
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Uploading new version. Processing will happen in the background.`
)
// Refresh metadata to reflect that versions changed (when ready)
this.openDocumentService.refreshDocument(this.documentId)
},
error: (error) => {
this.toastService.showError(
$localize`Error uploading new version`,
error
)
},
})
}
download(original: boolean = false) { download(original: boolean = false) {
this.downloading = true this.downloading = true
const downloadUrl = this.documentsService.getDownloadUrl( const downloadUrl = this.documentsService.getDownloadUrl(
this.documentId, this.selectedVersionId || this.documentId,
original original
) )
this.http this.http

View File

@@ -159,6 +159,10 @@ export interface Document extends ObjectWithPermissions {
page_count?: number page_count?: number
// Versioning
head_version?: number
versions?: number[]
// Frontend only // Frontend only
__changedFields?: string[] __changedFields?: string[]
} }

View File

@@ -163,12 +163,19 @@ export class DocumentService extends AbstractPaperlessService<Document> {
}) })
} }
getPreviewUrl(id: number, original: boolean = false): string { getPreviewUrl(
id: number,
original: boolean = false,
versionID: number = null
): string {
let url = new URL(this.getResourceUrl(id, 'preview')) let url = new URL(this.getResourceUrl(id, 'preview'))
if (this._searchQuery) url.hash = `#search="${this.searchQuery}"` if (this._searchQuery) url.hash = `#search="${this.searchQuery}"`
if (original) { if (original) {
url.searchParams.append('original', 'true') url.searchParams.append('original', 'true')
} }
if (versionID) {
url.searchParams.append('version', versionID.toString())
}
return url.toString() return url.toString()
} }
@@ -184,6 +191,16 @@ export class DocumentService extends AbstractPaperlessService<Document> {
return url return url
} }
uploadVersion(documentId: number, file: File) {
const formData = new FormData()
formData.append('document', file, file.name)
return this.http.post(
this.getResourceUrl(documentId, 'update_version'),
formData,
{ reportProgress: true, observe: 'events' }
)
}
getNextAsn(): Observable<number> { getNextAsn(): Observable<number> {
return this.http.get<number>(this.getResourceUrl(null, 'next_asn')) return this.http.get<number>(this.getResourceUrl(null, 'next_asn'))
} }

View File

@@ -77,6 +77,7 @@ import {
fileEarmarkFill, fileEarmarkFill,
fileEarmarkLock, fileEarmarkLock,
fileEarmarkMinus, fileEarmarkMinus,
fileEarmarkPlus,
fileEarmarkRichtext, fileEarmarkRichtext,
fileText, fileText,
files, files,
@@ -286,6 +287,7 @@ const icons = {
fileEarmarkFill, fileEarmarkFill,
fileEarmarkLock, fileEarmarkLock,
fileEarmarkMinus, fileEarmarkMinus,
fileEarmarkPlus,
fileEarmarkRichtext, fileEarmarkRichtext,
files, files,
fileText, fileText,

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import itertools import itertools
import logging import logging
import tempfile import tempfile
@@ -283,10 +282,8 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]:
f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.", f"Attempting to rotate {len(doc_ids)} documents by {degrees} degrees.",
) )
qs = Document.objects.filter(id__in=doc_ids) qs = Document.objects.filter(id__in=doc_ids)
affected_docs: list[int] = []
import pikepdf import pikepdf
rotate_tasks = []
for doc in qs: for doc in qs:
if doc.mime_type != "application/pdf": if doc.mime_type != "application/pdf":
logger.warning( logger.warning(
@@ -294,28 +291,34 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]:
) )
continue continue
try: try:
with pikepdf.open(doc.source_path, allow_overwriting_input=True) as pdf: # Write rotated output to a temp file and create a new version via consume pipeline
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{doc.id}_rotated.pdf"
)
with pikepdf.open(doc.source_path) as pdf:
for page in pdf.pages: for page in pdf.pages:
page.rotate(degrees, relative=True) page.rotate(degrees, relative=True)
pdf.save() pdf.remove_unreferenced_resources()
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest() pdf.save(filepath)
doc.save()
rotate_tasks.append( # Preserve metadata/permissions via overrides; mark as new version
update_document_content_maybe_archive_file.s( overrides = DocumentMetadataOverrides().from_document(doc)
document_id=doc.id,
), consume_file.delay(
) ConsumableDocument(
logger.info( source=DocumentSource.ConsumeFolder,
f"Rotated document {doc.id} by {degrees} degrees", original_file=filepath,
) head_version_id=doc.id,
affected_docs.append(doc.id) ),
overrides,
)
logger.info(
f"Queued new rotated version for document {doc.id} by {degrees} degrees",
)
except Exception as e: except Exception as e:
logger.exception(f"Error rotating document {doc.id}: {e}") logger.exception(f"Error rotating document {doc.id}: {e}")
if len(affected_docs) > 0:
bulk_update_task = bulk_update_documents.si(document_ids=affected_docs)
chord(header=rotate_tasks, body=bulk_update_task).delay()
return "OK" return "OK"
@@ -478,19 +481,31 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
import pikepdf import pikepdf
try: try:
with pikepdf.open(doc.source_path, allow_overwriting_input=True) as pdf: # Produce edited PDF to a temp file and create a new version
filepath: Path = (
Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
/ f"{doc.id}_pages_deleted.pdf"
)
with pikepdf.open(doc.source_path) as pdf:
offset = 1 # pages are 1-indexed offset = 1 # pages are 1-indexed
for page_num in pages: for page_num in pages:
pdf.pages.remove(pdf.pages[page_num - offset]) pdf.pages.remove(pdf.pages[page_num - offset])
offset += 1 # remove() changes the index of the pages offset += 1 # remove() changes the index of the pages
pdf.remove_unreferenced_resources() pdf.remove_unreferenced_resources()
pdf.save() pdf.save(filepath)
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
if doc.page_count is not None: overrides = DocumentMetadataOverrides().from_document(doc)
doc.page_count = doc.page_count - len(pages) consume_file.delay(
doc.save() ConsumableDocument(
update_document_content_maybe_archive_file.delay(document_id=doc.id) source=DocumentSource.ConsumeFolder,
logger.info(f"Deleted pages {pages} from document {doc.id}") original_file=filepath,
head_version_id=doc.id,
),
overrides,
)
logger.info(
f"Queued new version for document {doc.id} after deleting pages {pages}",
)
except Exception as e: except Exception as e:
logger.exception(f"Error deleting pages from document {doc.id}: {e}") logger.exception(f"Error deleting pages from document {doc.id}: {e}")
@@ -542,17 +557,29 @@ def edit_pdf(
dst.pages[-1].rotate(op["rotate"], relative=True) dst.pages[-1].rotate(op["rotate"], relative=True)
if update_document: if update_document:
temp_path = doc.source_path.with_suffix(".tmp.pdf") # Create a new version from the edited PDF rather than replacing in-place
pdf = pdf_docs[0] pdf = pdf_docs[0]
pdf.remove_unreferenced_resources() pdf.remove_unreferenced_resources()
# save the edited PDF to a temporary file in case of errors filepath: Path = (
pdf.save(temp_path) Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR))
# replace the original document with the edited one / f"{doc.id}_edited.pdf"
temp_path.replace(doc.source_path) )
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest() pdf.save(filepath)
doc.page_count = len(pdf.pages) overrides = (
doc.save() DocumentMetadataOverrides().from_document(doc)
update_document_content_maybe_archive_file.delay(document_id=doc.id) if include_metadata
else DocumentMetadataOverrides()
)
if user is not None:
overrides.owner_id = user.id
consume_file.delay(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=filepath,
head_version_id=doc.id,
),
overrides,
)
else: else:
consume_tasks = [] consume_tasks = []
overrides = ( overrides = (

View File

@@ -14,6 +14,51 @@ from documents.classifier import DocumentClassifier
from documents.models import Document from documents.models import Document
def _resolve_effective_doc(pk: int, request) -> Document | None:
"""
Resolve which Document row should be considered for caching keys:
- If a version is requested, use that version
- If pk is a head doc, use its newest child version if present, else the head.
- Else, pk is a version, use that version.
Returns None if resolution fails (treat as no-cache).
"""
try:
request_doc = Document.objects.only("id", "head_version_id").get(pk=pk)
except Document.DoesNotExist:
return None
head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.objects.only("id").get(id=request_doc.head_version_id)
)
version_param = (
request.query_params.get("version")
if hasattr(request, "query_params")
else None
)
if version_param:
try:
version_id = int(version_param)
candidate = Document.objects.only("id", "head_version_id").get(
id=version_id,
)
if candidate.id != head_doc.id and candidate.head_version_id != head_doc.id:
return None
return candidate
except Exception:
return None
# Default behavior: if pk is a head doc, prefer its newest child version
if request_doc.head_version_id is None:
latest = head_doc.versions.only("id").order_by("id").last()
return latest or head_doc
# pk is already a version
return request_doc
def suggestions_etag(request, pk: int) -> str | None: def suggestions_etag(request, pk: int) -> str | None:
""" """
Returns an optional string for the ETag, allowing browser caching of Returns an optional string for the ETag, allowing browser caching of
@@ -71,11 +116,10 @@ def metadata_etag(request, pk: int) -> str | None:
Metadata is extracted from the original file, so use its checksum as the Metadata is extracted from the original file, so use its checksum as the
ETag ETag
""" """
try: doc = _resolve_effective_doc(pk, request)
doc = Document.objects.only("checksum").get(pk=pk) if doc is None:
return doc.checksum
except Document.DoesNotExist: # pragma: no cover
return None return None
return doc.checksum
return None return None
@@ -85,11 +129,10 @@ def metadata_last_modified(request, pk: int) -> datetime | None:
not the modification of the original file, but of the database object, but might as well not the modification of the original file, but of the database object, but might as well
error on the side of more cautious error on the side of more cautious
""" """
try: doc = _resolve_effective_doc(pk, request)
doc = Document.objects.only("modified").get(pk=pk) if doc is None:
return doc.modified
except Document.DoesNotExist: # pragma: no cover
return None return None
return doc.modified
return None return None
@@ -97,15 +140,15 @@ def preview_etag(request, pk: int) -> str | None:
""" """
ETag for the document preview, using the original or archive checksum, depending on the request ETag for the document preview, using the original or archive checksum, depending on the request
""" """
try: doc = _resolve_effective_doc(pk, request)
doc = Document.objects.only("checksum", "archive_checksum").get(pk=pk) if doc is None:
use_original = (
"original" in request.query_params
and request.query_params["original"] == "true"
)
return doc.checksum if use_original else doc.archive_checksum
except Document.DoesNotExist: # pragma: no cover
return None return None
use_original = (
hasattr(request, "query_params")
and "original" in request.query_params
and request.query_params["original"] == "true"
)
return doc.checksum if use_original else doc.archive_checksum
return None return None
@@ -114,11 +157,10 @@ def preview_last_modified(request, pk: int) -> datetime | None:
Uses the documents modified time to set the Last-Modified header. Not strictly Uses the documents modified time to set the Last-Modified header. Not strictly
speaking correct, but close enough and quick speaking correct, but close enough and quick
""" """
try: doc = _resolve_effective_doc(pk, request)
doc = Document.objects.only("modified").get(pk=pk) if doc is None:
return doc.modified
except Document.DoesNotExist: # pragma: no cover
return None return None
return doc.modified
return None return None
@@ -128,10 +170,13 @@ def thumbnail_last_modified(request, pk: int) -> datetime | None:
Cache should be (slightly?) faster than filesystem Cache should be (slightly?) faster than filesystem
""" """
try: try:
doc = Document.objects.only("storage_type").get(pk=pk) doc = _resolve_effective_doc(pk, request)
if doc is None:
return None
if not doc.thumbnail_path.exists(): if not doc.thumbnail_path.exists():
return None return None
doc_key = get_thumbnail_modified_key(pk) # Use the effective document id for cache key
doc_key = get_thumbnail_modified_key(doc.id)
cache_hit = cache.get(doc_key) cache_hit = cache.get(doc_key)
if cache_hit is not None: if cache_hit is not None:

View File

@@ -113,6 +113,12 @@ class ConsumerPluginMixin:
self.filename = self.metadata.filename or self.input_doc.original_file.name self.filename = self.metadata.filename or self.input_doc.original_file.name
if input_doc.head_version_id:
self.log.debug(f"Document head version id: {input_doc.head_version_id}")
head_version = Document.objects.get(pk=input_doc.head_version_id)
version_index = head_version.versions.count()
self.filename += f"_v{version_index}"
def _send_progress( def _send_progress(
self, self,
current_progress: int, current_progress: int,
@@ -470,12 +476,44 @@ class ConsumerPlugin(
try: try:
with transaction.atomic(): with transaction.atomic():
# store the document. # store the document.
document = self._store( if self.input_doc.head_version_id:
text=text, # If this is a new version of an existing document, we need
date=date, # to make sure we're not creating a new document, but updating
page_count=page_count, # the existing one.
mime_type=mime_type, original_document = Document.objects.get(
) pk=self.input_doc.head_version_id,
)
self.log.debug("Saving record for updated version to database")
original_document.pk = None
original_document.head_version = Document.objects.get(
pk=self.input_doc.head_version_id,
)
file_for_checksum = (
self.unmodified_original
if self.unmodified_original is not None
else self.working_copy
)
original_document.checksum = hashlib.md5(
file_for_checksum.read_bytes(),
).hexdigest()
original_document.content = text
original_document.page_count = page_count
original_document.mime_type = mime_type
original_document.original_filename = self.filename
# Clear unique file path fields so they can be generated uniquely later
original_document.filename = None
original_document.archive_filename = None
original_document.archive_checksum = None
original_document.modified = timezone.now()
original_document.save()
document = original_document
else:
document = self._store(
text=text,
date=date,
page_count=page_count,
mime_type=mime_type,
)
# If we get here, it was successful. Proceed with post-consume # If we get here, it was successful. Proceed with post-consume
# hooks. If they fail, nothing will get changed. # hooks. If they fail, nothing will get changed.

View File

@@ -156,6 +156,7 @@ class ConsumableDocument:
source: DocumentSource source: DocumentSource
original_file: Path original_file: Path
head_version_id: int | None = None
mailrule_id: int | None = None mailrule_id: int | None = None
mime_type: str = dataclasses.field(init=False, default=None) mime_type: str = dataclasses.field(init=False, default=None)

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.1.6 on 2025-02-26 17:08
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1068_alter_document_created"),
]
operations = [
migrations.AddField(
model_name="document",
name="head_version",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="documents.document",
verbose_name="head version of document",
),
),
]

View File

@@ -289,6 +289,15 @@ class Document(SoftDeleteModel, ModelWithOwner):
), ),
) )
head_version = models.ForeignKey(
"self",
blank=True,
null=True,
related_name="versions",
on_delete=models.CASCADE,
verbose_name=_("head version of document"),
)
class Meta: class Meta:
ordering = ("-created",) ordering = ("-created",)
verbose_name = _("document") verbose_name = _("document")

View File

@@ -957,6 +957,10 @@ class DocumentSerializer(
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"], request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
) )
if doc.get("versions") is not None:
doc["versions"] = sorted(doc["versions"], reverse=True)
doc["versions"].append(doc["id"])
if api_version < 9: if api_version < 9:
# provide created as a datetime for backwards compatibility # provide created as a datetime for backwards compatibility
from django.utils import timezone from django.utils import timezone
@@ -1103,6 +1107,8 @@ class DocumentSerializer(
"remove_inbox_tags", "remove_inbox_tags",
"page_count", "page_count",
"mime_type", "mime_type",
"head_version",
"versions",
) )
list_serializer_class = OwnedObjectListSerializer list_serializer_class = OwnedObjectListSerializer
@@ -1738,6 +1744,15 @@ class PostDocumentSerializer(serializers.Serializer):
return created.date() return created.date()
class DocumentVersionSerializer(serializers.Serializer):
document = serializers.FileField(
label="Document",
write_only=True,
)
validate_document = PostDocumentSerializer().validate_document
class BulkDownloadSerializer(DocumentListSerializer): class BulkDownloadSerializer(DocumentListSerializer):
content = serializers.ChoiceField( content = serializers.ChoiceField(
choices=["archive", "originals", "both"], choices=["archive", "originals", "both"],

View File

@@ -145,13 +145,17 @@ def consume_file(
if overrides is None: if overrides is None:
overrides = DocumentMetadataOverrides() overrides = DocumentMetadataOverrides()
plugins: list[type[ConsumeTaskPlugin]] = [ plugins: list[type[ConsumeTaskPlugin]] = (
ConsumerPreflightPlugin, [ConsumerPreflightPlugin,ConsumerPlugin]
CollatePlugin, if input_doc.head_version_id is not None
BarcodePlugin, else [
WorkflowTriggerPlugin, ConsumerPreflightPlugin,
ConsumerPlugin, CollatePlugin,
] BarcodePlugin,
WorkflowTriggerPlugin,
ConsumerPlugin,
]
)
with ( with (
ProgressManager( ProgressManager(

View File

@@ -147,6 +147,7 @@ from documents.serialisers import CustomFieldSerializer
from documents.serialisers import DocumentListSerializer from documents.serialisers import DocumentListSerializer
from documents.serialisers import DocumentSerializer from documents.serialisers import DocumentSerializer
from documents.serialisers import DocumentTypeSerializer from documents.serialisers import DocumentTypeSerializer
from documents.serialisers import DocumentVersionSerializer
from documents.serialisers import NotesSerializer from documents.serialisers import NotesSerializer
from documents.serialisers import PostDocumentSerializer from documents.serialisers import PostDocumentSerializer
from documents.serialisers import RunTaskViewSerializer from documents.serialisers import RunTaskViewSerializer
@@ -559,7 +560,7 @@ class DocumentViewSet(
GenericViewSet, GenericViewSet,
): ):
model = Document model = Document
queryset = Document.objects.annotate(num_notes=Count("notes")) queryset = Document.objects.all()
serializer_class = DocumentSerializer serializer_class = DocumentSerializer
pagination_class = StandardPagination pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
@@ -588,7 +589,8 @@ class DocumentViewSet(
def get_queryset(self): def get_queryset(self):
return ( return (
Document.objects.distinct() Document.objects.filter(head_version__isnull=True)
.distinct()
.order_by("-created") .order_by("-created")
.annotate(num_notes=Count("notes")) .annotate(num_notes=Count("notes"))
.select_related("correspondent", "storage_path", "document_type", "owner") .select_related("correspondent", "storage_path", "document_type", "owner")
@@ -650,18 +652,55 @@ class DocumentViewSet(
and request.query_params["original"] == "true" and request.query_params["original"] == "true"
) )
def _resolve_file_doc(self, head_doc: Document, request):
version_param = request.query_params.get("version")
if version_param:
try:
version_id = int(version_param)
except (TypeError, ValueError):
raise NotFound("Invalid version parameter")
try:
candidate = Document.global_objects.select_related("owner").get(
id=version_id,
)
except Document.DoesNotExist:
raise Http404
if candidate.id != head_doc.id and candidate.head_version_id != head_doc.id:
raise Http404
return candidate
latest = head_doc.versions.order_by("id").last()
return latest or head_doc
def file_response(self, pk, request, disposition): def file_response(self, pk, request, disposition):
doc = Document.global_objects.select_related("owner").get(id=pk) request_doc = Document.global_objects.select_related("owner").get(id=pk)
head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.global_objects.select_related("owner").get(
id=request_doc.head_version_id,
)
)
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
doc, head_doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
# If a version is explicitly requested, use it. Otherwise:
# - if pk is a head document: serve newest version
# - if pk is a version: serve that version
if "version" in request.query_params:
file_doc = self._resolve_file_doc(head_doc, request)
else:
file_doc = (
self._resolve_file_doc(head_doc, request)
if request_doc.head_version_id is None
else request_doc
)
return serve_file( return serve_file(
doc=doc, doc=file_doc,
use_archive=not self.original_requested(request) use_archive=not self.original_requested(request)
and doc.has_archive_version, and file_doc.has_archive_version,
disposition=disposition, disposition=disposition,
) )
@@ -696,16 +735,33 @@ class DocumentViewSet(
) )
def metadata(self, request, pk=None): def metadata(self, request, pk=None):
try: try:
doc = Document.objects.select_related("owner").get(pk=pk) request_doc = Document.objects.select_related("owner").get(pk=pk)
head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.objects.select_related("owner").get(
id=request_doc.head_version_id,
)
)
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
doc, head_doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404 raise Http404
# Choose the effective document (newest version by default, or explicit via ?version=)
if "version" in request.query_params:
doc = self._resolve_file_doc(head_doc, request)
else:
doc = (
self._resolve_file_doc(head_doc, request)
if request_doc.head_version_id is None
else request_doc
)
document_cached_metadata = get_metadata_cache(doc.pk) document_cached_metadata = get_metadata_cache(doc.pk)
archive_metadata = None archive_metadata = None
@@ -807,8 +863,36 @@ class DocumentViewSet(
) )
def preview(self, request, pk=None): def preview(self, request, pk=None):
try: try:
response = self.file_response(pk, request, "inline") request_doc = Document.objects.select_related("owner").get(id=pk)
return response head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.objects.select_related("owner").get(
id=request_doc.head_version_id,
)
)
if request.user is not None and not has_perms_owner_aware(
request.user,
"view_document",
head_doc,
):
return HttpResponseForbidden("Insufficient permissions")
if "version" in request.query_params:
file_doc = self._resolve_file_doc(head_doc, request)
else:
file_doc = (
self._resolve_file_doc(head_doc, request)
if request_doc.head_version_id is None
else request_doc
)
return serve_file(
doc=file_doc,
use_archive=not self.original_requested(request)
and file_doc.has_archive_version,
disposition="inline",
)
except (FileNotFoundError, Document.DoesNotExist): except (FileNotFoundError, Document.DoesNotExist):
raise Http404 raise Http404
@@ -817,17 +901,32 @@ class DocumentViewSet(
@method_decorator(last_modified(thumbnail_last_modified)) @method_decorator(last_modified(thumbnail_last_modified))
def thumb(self, request, pk=None): def thumb(self, request, pk=None):
try: try:
doc = Document.objects.select_related("owner").get(id=pk) request_doc = Document.objects.select_related("owner").get(id=pk)
head_doc = (
request_doc
if request_doc.head_version_id is None
else Document.objects.select_related("owner").get(
id=request_doc.head_version_id,
)
)
if request.user is not None and not has_perms_owner_aware( if request.user is not None and not has_perms_owner_aware(
request.user, request.user,
"view_document", "view_document",
doc, head_doc,
): ):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
if doc.storage_type == Document.STORAGE_TYPE_GPG: if "version" in request.query_params:
handle = GnuPG.decrypted(doc.thumbnail_file) file_doc = self._resolve_file_doc(head_doc, request)
else: else:
handle = doc.thumbnail_file file_doc = (
self._resolve_file_doc(head_doc, request)
if request_doc.head_version_id is None
else request_doc
)
if file_doc.storage_type == Document.STORAGE_TYPE_GPG:
handle = GnuPG.decrypted(file_doc.thumbnail_file)
else:
handle = file_doc.thumbnail_file
return HttpResponse(handle, content_type="image/webp") return HttpResponse(handle, content_type="image/webp")
except (FileNotFoundError, Document.DoesNotExist): except (FileNotFoundError, Document.DoesNotExist):
@@ -1095,6 +1194,56 @@ class DocumentViewSet(
"Error emailing document, check logs for more detail.", "Error emailing document, check logs for more detail.",
) )
@action(methods=["post"], detail=True)
def update_version(self, request, pk=None):
serializer = DocumentVersionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
doc = Document.objects.select_related("owner").get(pk=pk)
if request.user is not None and not has_perms_owner_aware(
request.user,
"change_document",
doc,
):
return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist:
raise Http404
try:
doc_name, doc_data = serializer.validated_data.get("document")
t = int(mktime(datetime.now().timetuple()))
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
temp_file_path = Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / Path(
pathvalidate.sanitize_filename(doc_name),
)
temp_file_path.write_bytes(doc_data)
os.utime(temp_file_path, times=(t, t))
input_doc = ConsumableDocument(
source=DocumentSource.ApiUpload,
original_file=temp_file_path,
head_version_id=doc.pk,
)
async_task = consume_file.delay(
input_doc,
)
logger.debug(
f"Updated document {doc.id} with new version",
)
return Response(async_task.id)
except Exception as e:
logger.warning(f"An error occurred updating document: {e!s}")
return HttpResponseServerError(
"Error updating document, check logs for more detail.",
)
@extend_schema_view( @extend_schema_view(
list=extend_schema( list=extend_schema(