mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-08 21:23:44 -05:00
Compare commits
10 Commits
dev
...
feature-do
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f3e664b577 | ||
![]() |
50a5192c89 | ||
![]() |
3a6bcec27c | ||
![]() |
6a58d3ed20 | ||
![]() |
7e788a0ecd | ||
![]() |
dac15ea6be | ||
![]() |
67f9375d77 | ||
![]() |
9e8b6071a3 | ||
![]() |
8897307a10 | ||
![]() |
26757e4930 |
@@ -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> ✓</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>
|
||||||
|
@@ -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
|
||||||
|
@@ -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[]
|
||||||
}
|
}
|
||||||
|
@@ -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'))
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
@@ -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 = (
|
||||||
|
@@ -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:
|
||||||
|
@@ -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.
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
26
src/documents/migrations/1069_document_head_version.py
Normal file
26
src/documents/migrations/1069_document_head_version.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@@ -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")
|
||||||
|
@@ -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"],
|
||||||
|
@@ -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(
|
||||||
|
@@ -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(
|
||||||
|
Reference in New Issue
Block a user