Merged upstream

This commit is contained in:
YNorbert Klamann 2024-11-21 12:43:44 +00:00
commit b0714280bc
24 changed files with 293 additions and 84 deletions

View File

@ -556,3 +556,11 @@ Initial API version.
- Consumption templates were refactored to workflows and API endpoints - Consumption templates were refactored to workflows and API endpoints
changed as such. changed as such.
#### Version 5
- Added bulk deletion methods for documents and objects.
#### Version 6
- Moved acknowledge tasks endpoint to be under `/api/tasks/acknowledge/`.

View File

@ -253,6 +253,10 @@
<context context-type="sourcefile">src/app/app.component.ts</context> <context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">87</context> <context context-type="linenumber">87</context>
</context-group> </context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">118</context>
</context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context> <context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">37</context> <context context-type="linenumber">37</context>
@ -1480,11 +1484,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">57</context> <context context-type="linenumber">59</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">86</context> <context context-type="linenumber">88</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context> <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
@ -2216,11 +2220,11 @@
<source>Confirm delete</source> <source>Confirm delete</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">53</context> <context context-type="linenumber">55</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">80</context> <context context-type="linenumber">82</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
@ -2235,18 +2239,18 @@
<source>This operation will permanently delete this document.</source> <source>This operation will permanently delete this document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">54</context> <context context-type="linenumber">56</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5641451190833696892" datatype="html"> <trans-unit id="5641451190833696892" datatype="html">
<source>This operation cannot be undone.</source> <source>This operation cannot be undone.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">55</context> <context context-type="linenumber">57</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">84</context> <context context-type="linenumber">86</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@ -2281,14 +2285,14 @@
<source>Document deleted</source> <source>Document deleted</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">64</context> <context context-type="linenumber">66</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7295637485862454066" datatype="html"> <trans-unit id="7295637485862454066" datatype="html">
<source>Error deleting document</source> <source>Error deleting document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">69</context> <context context-type="linenumber">71</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
@ -2299,56 +2303,56 @@
<source>This operation will permanently delete the selected documents.</source> <source>This operation will permanently delete the selected documents.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">82</context> <context context-type="linenumber">84</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6804051092296228130" datatype="html"> <trans-unit id="6804051092296228130" datatype="html">
<source>This operation will permanently delete all documents in the trash.</source> <source>This operation will permanently delete all documents in the trash.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">83</context> <context context-type="linenumber">85</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6996183233986182894" datatype="html"> <trans-unit id="6996183233986182894" datatype="html">
<source>Document(s) deleted</source> <source>Document(s) deleted</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">94</context> <context context-type="linenumber">96</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6962724852893361467" datatype="html"> <trans-unit id="6962724852893361467" datatype="html">
<source>Error deleting document(s)</source> <source>Error deleting document(s)</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">101</context> <context context-type="linenumber">103</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7534569062269274401" datatype="html"> <trans-unit id="7534569062269274401" datatype="html">
<source>Document restored</source> <source>Document restored</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">113</context> <context context-type="linenumber">116</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9136016619414048201" datatype="html"> <trans-unit id="9136016619414048201" datatype="html">
<source>Error restoring document</source> <source>Error restoring document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">117</context> <context context-type="linenumber">126</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="960063472770266304" datatype="html"> <trans-unit id="960063472770266304" datatype="html">
<source>Document(s) restored</source> <source>Document(s) restored</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">127</context> <context context-type="linenumber">136</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8405416976953346141" datatype="html"> <trans-unit id="8405416976953346141" datatype="html">
<source>Error restoring document(s)</source> <source>Error restoring document(s)</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context> <context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">133</context> <context context-type="linenumber">142</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8119815638230251386" datatype="html"> <trans-unit id="8119815638230251386" datatype="html">
@ -5437,36 +5441,36 @@
<source>TOTP activated successfully</source> <source>TOTP activated successfully</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">263</context> <context context-type="linenumber">264</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3755006064892435830" datatype="html"> <trans-unit id="3755006064892435830" datatype="html">
<source>Error activating TOTP</source> <source>Error activating TOTP</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">265</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">271</context> <context context-type="linenumber">272</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5919827473541889422" datatype="html"> <trans-unit id="5919827473541889422" datatype="html">
<source>TOTP deactivated successfully</source> <source>TOTP deactivated successfully</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">287</context> <context context-type="linenumber">288</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6214722303383624015" datatype="html"> <trans-unit id="6214722303383624015" datatype="html">
<source>Error deactivating TOTP</source> <source>Error deactivating TOTP</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">289</context> <context context-type="linenumber">290</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">294</context> <context context-type="linenumber">295</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3797570084942068182" datatype="html"> <trans-unit id="3797570084942068182" datatype="html">

View File

@ -16,6 +16,7 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { Router } from '@angular/router'
const documentsInTrash = [ const documentsInTrash = [
{ {
@ -38,6 +39,7 @@ describe('TrashComponent', () => {
let trashService: TrashService let trashService: TrashService
let modalService: NgbModal let modalService: NgbModal
let toastService: ToastService let toastService: ToastService
let router: Router
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@ -61,6 +63,7 @@ describe('TrashComponent', () => {
trashService = TestBed.inject(TrashService) trashService = TestBed.inject(TrashService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
@ -161,6 +164,22 @@ describe('TrashComponent', () => {
expect(restoreSpy).toHaveBeenCalledWith([1, 2]) expect(restoreSpy).toHaveBeenCalledWith([1, 2])
}) })
it('should offer link to restored document', () => {
let toasts
const navigateSpy = jest.spyOn(router, 'navigate')
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
jest.spyOn(trashService, 'restoreDocuments').mockReturnValue(of('OK'))
component.restore(documentsInTrash[0])
expect(toasts.length).toEqual(1)
toasts[0].action()
expect(navigateSpy).toHaveBeenCalledWith([
'documents',
documentsInTrash[0].id,
])
})
it('should support toggle all items in view', () => { it('should support toggle all items in view', () => {
component.documentsInTrash = documentsInTrash component.documentsInTrash = documentsInTrash
expect(component.selectedDocuments.size).toEqual(0) expect(component.selectedDocuments.size).toEqual(0)

View File

@ -7,6 +7,7 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
import { Subject, takeUntil } from 'rxjs' import { Subject, takeUntil } from 'rxjs'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Router } from '@angular/router'
@Component({ @Component({
selector: 'pngx-trash', selector: 'pngx-trash',
@ -26,7 +27,8 @@ export class TrashComponent implements OnDestroy {
private trashService: TrashService, private trashService: TrashService,
private toastService: ToastService, private toastService: ToastService,
private modalService: NgbModal, private modalService: NgbModal,
private settingsService: SettingsService private settingsService: SettingsService,
private router: Router
) { ) {
this.reload() this.reload()
} }
@ -110,7 +112,14 @@ export class TrashComponent implements OnDestroy {
restore(document: Document) { restore(document: Document) {
this.trashService.restoreDocuments([document.id]).subscribe({ this.trashService.restoreDocuments([document.id]).subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document restored`) this.toastService.show({
content: $localize`Document restored`,
delay: 5000,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', document.id])
},
})
this.reload() this.reload()
}, },
error: (err) => { error: (err) => {

View File

@ -30,4 +30,6 @@ export interface PaperlessTask extends ObjectWithId {
result?: string result?: string
related_document?: number related_document?: number
owner?: number
} }

View File

@ -48,7 +48,7 @@ describe('TasksService', () => {
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => { it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
tasksService.dismissTasks(new Set([1, 2, 3])) tasksService.dismissTasks(new Set([1, 2, 3]))
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}acknowledge_tasks/` `${environment.apiBaseUrl}tasks/acknowledge/`
) )
expect(req.request.method).toEqual('POST') expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({ expect(req.request.body).toEqual({

View File

@ -64,7 +64,7 @@ export class TasksService {
public dismissTasks(task_ids: Set<number>) { public dismissTasks(task_ids: Set<number>) {
this.http this.http
.post(`${this.baseUrl}acknowledge_tasks/`, { .post(`${this.baseUrl}tasks/acknowledge/`, {
tasks: [...task_ids], tasks: [...task_ids],
}) })
.pipe(first()) .pipe(first())

View File

@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
export const environment = { export const environment = {
production: true, production: true,
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '5', apiVersion: '6',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: '2.13.5', version: '2.13.5',
webSocketHost: window.location.host, webSocketHost: window.location.host,

View File

@ -5,7 +5,7 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'http://localhost:8000/api/', apiBaseUrl: 'http://localhost:8000/api/',
apiVersion: '5', apiVersion: '6',
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
version: 'DEVELOPMENT', version: 'DEVELOPMENT',
webSocketHost: 'localhost:8000', webSocketHost: 'localhost:8000',

View File

@ -24,7 +24,7 @@ from documents.models import StoragePath
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
from documents.tasks import consume_file from documents.tasks import consume_file
from documents.tasks import update_document_archive_file from documents.tasks import update_document_content_maybe_archive_file
logger: logging.Logger = logging.getLogger("paperless.bulk_edit") logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
@ -191,7 +191,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
def reprocess(doc_ids: list[int]) -> Literal["OK"]: def reprocess(doc_ids: list[int]) -> Literal["OK"]:
for document_id in doc_ids: for document_id in doc_ids:
update_document_archive_file.delay( update_document_content_maybe_archive_file.delay(
document_id=document_id, document_id=document_id,
) )
@ -245,7 +245,7 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]:
doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest() doc.checksum = hashlib.md5(doc.source_path.read_bytes()).hexdigest()
doc.save() doc.save()
rotate_tasks.append( rotate_tasks.append(
update_document_archive_file.s( update_document_content_maybe_archive_file.s(
document_id=doc.id, document_id=doc.id,
), ),
) )
@ -423,7 +423,7 @@ def delete_pages(doc_ids: list[int], pages: list[int]) -> Literal["OK"]:
if doc.page_count is not None: if doc.page_count is not None:
doc.page_count = doc.page_count - len(pages) doc.page_count = doc.page_count - len(pages)
doc.save() doc.save()
update_document_archive_file.delay(document_id=doc.id) update_document_content_maybe_archive_file.delay(document_id=doc.id)
logger.info(f"Deleted pages {pages} from document {doc.id}") logger.info(f"Deleted pages {pages} from document {doc.id}")
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}")

View File

@ -9,7 +9,7 @@ from django.core.management.base import BaseCommand
from documents.management.commands.mixins import MultiProcessMixin from documents.management.commands.mixins import MultiProcessMixin
from documents.management.commands.mixins import ProgressBarMixin from documents.management.commands.mixins import ProgressBarMixin
from documents.models import Document from documents.models import Document
from documents.tasks import update_document_archive_file from documents.tasks import update_document_content_maybe_archive_file
logger = logging.getLogger("paperless.management.archiver") logger = logging.getLogger("paperless.management.archiver")
@ -77,13 +77,13 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand):
if self.process_count == 1: if self.process_count == 1:
for doc_id in document_ids: for doc_id in document_ids:
update_document_archive_file(doc_id) update_document_content_maybe_archive_file(doc_id)
else: # pragma: no cover else: # pragma: no cover
with multiprocessing.Pool(self.process_count) as pool: with multiprocessing.Pool(self.process_count) as pool:
list( list(
tqdm.tqdm( tqdm.tqdm(
pool.imap_unordered( pool.imap_unordered(
update_document_archive_file, update_document_content_maybe_archive_file,
document_ids, document_ids,
), ),
total=len(document_ids), total=len(document_ids),

View File

@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2024-11-04 21:56
import django.db.models.deletion
from django.conf import settings
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1056_customfieldinstance_deleted_at_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="paperlesstask",
name="owner",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
]

View File

@ -641,7 +641,7 @@ class UiSettings(models.Model):
return self.user.username return self.user.username
class PaperlessTask(models.Model): class PaperlessTask(ModelWithOwner):
ALL_STATES = sorted(states.ALL_STATES) ALL_STATES = sorted(states.ALL_STATES)
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES)) TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))

View File

@ -1567,7 +1567,7 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
return ui_settings return ui_settings
class TasksViewSerializer(serializers.ModelSerializer): class TasksViewSerializer(OwnedObjectSerializer):
class Meta: class Meta:
model = PaperlessTask model = PaperlessTask
depth = 1 depth = 1
@ -1582,6 +1582,7 @@ class TasksViewSerializer(serializers.ModelSerializer):
"result", "result",
"acknowledged", "acknowledged",
"related_document", "related_document",
"owner",
) )
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()

View File

@ -939,9 +939,10 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
close_old_connections() close_old_connections()
task_args = body[0] task_args = body[0]
input_doc, _ = task_args input_doc, overrides = task_args
task_file_name = input_doc.original_file.name task_file_name = input_doc.original_file.name
user_id = overrides.owner_id if overrides else None
PaperlessTask.objects.create( PaperlessTask.objects.create(
task_id=headers["id"], task_id=headers["id"],
@ -952,6 +953,7 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
date_created=timezone.now(), date_created=timezone.now(),
date_started=None, date_started=None,
date_done=None, date_done=None,
owner_id=user_id,
) )
except Exception: # pragma: no cover except Exception: # pragma: no cover
# Don't let an exception in the signal handlers prevent # Don't let an exception in the signal handlers prevent

View File

@ -206,9 +206,10 @@ def bulk_update_documents(document_ids):
@shared_task @shared_task
def update_document_archive_file(document_id): def update_document_content_maybe_archive_file(document_id):
""" """
Re-creates the archive file of a document, including new OCR content and thumbnail Re-creates OCR content and thumbnail for a document, and archive file if
it exists.
""" """
document = Document.objects.get(id=document_id) document = Document.objects.get(id=document_id)
@ -234,8 +235,9 @@ def update_document_archive_file(document_id):
document.get_public_filename(), document.get_public_filename(),
) )
if parser.get_archive_path(): with transaction.atomic():
with transaction.atomic(): oldDocument = Document.objects.get(pk=document.pk)
if parser.get_archive_path():
with open(parser.get_archive_path(), "rb") as f: with open(parser.get_archive_path(), "rb") as f:
checksum = hashlib.md5(f.read()).hexdigest() checksum = hashlib.md5(f.read()).hexdigest()
# I'm going to save first so that in case the file move # I'm going to save first so that in case the file move
@ -246,7 +248,6 @@ def update_document_archive_file(document_id):
document, document,
archive_filename=True, archive_filename=True,
) )
oldDocument = Document.objects.get(pk=document.pk)
Document.objects.filter(pk=document.pk).update( Document.objects.filter(pk=document.pk).update(
archive_checksum=checksum, archive_checksum=checksum,
content=parser.get_text(), content=parser.get_text(),
@ -268,24 +269,41 @@ def update_document_archive_file(document_id):
], ],
}, },
additional_data={ additional_data={
"reason": "Update document archive file", "reason": "Update document content",
},
action=LogEntry.Action.UPDATE,
)
else:
Document.objects.filter(pk=document.pk).update(
content=parser.get_text(),
)
if settings.AUDIT_LOG_ENABLED:
LogEntry.objects.log_create(
instance=oldDocument,
changes={
"content": [oldDocument.content, parser.get_text()],
},
additional_data={
"reason": "Update document content",
}, },
action=LogEntry.Action.UPDATE, action=LogEntry.Action.UPDATE,
) )
with FileLock(settings.MEDIA_LOCK): with FileLock(settings.MEDIA_LOCK):
if parser.get_archive_path():
create_source_path_directory(document.archive_path) create_source_path_directory(document.archive_path)
shutil.move(parser.get_archive_path(), document.archive_path) shutil.move(parser.get_archive_path(), document.archive_path)
shutil.move(thumbnail, document.thumbnail_path) shutil.move(thumbnail, document.thumbnail_path)
document.refresh_from_db() document.refresh_from_db()
logger.info( logger.info(
f"Updating index for document {document_id} ({document.archive_checksum})", f"Updating index for document {document_id} ({document.archive_checksum})",
) )
with index.open_index_writer() as writer: with index.open_index_writer() as writer:
index.update_document(writer, document) index.update_document(writer, document)
clear_document_caches(document.pk) clear_document_caches(document.pk)
except Exception: except Exception:
logger.exception( logger.exception(

View File

@ -1,6 +1,7 @@
import uuid import uuid
import celery import celery
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@ -11,7 +12,6 @@ from documents.tests.utils import DirectoriesMixin
class TestTasks(DirectoriesMixin, APITestCase): class TestTasks(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/tasks/" ENDPOINT = "/api/tasks/"
ENDPOINT_ACKNOWLEDGE = "/api/acknowledge_tasks/"
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -125,7 +125,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
self.assertEqual(len(response.data), 1) self.assertEqual(len(response.data), 1)
response = self.client.post( response = self.client.post(
self.ENDPOINT_ACKNOWLEDGE, self.ENDPOINT + "acknowledge/",
{"tasks": [task.id]}, {"tasks": [task.id]},
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -133,6 +133,52 @@ class TestTasks(DirectoriesMixin, APITestCase):
response = self.client.get(self.ENDPOINT) response = self.client.get(self.ENDPOINT)
self.assertEqual(len(response.data), 0) self.assertEqual(len(response.data), 0)
def test_tasks_owner_aware(self):
"""
GIVEN:
- Existing PaperlessTasks with owner and with no owner
WHEN:
- API call is made to get tasks
THEN:
- Only tasks with no owner or request user are returned
"""
regular_user = User.objects.create_user(username="test")
regular_user.user_permissions.add(*Permission.objects.all())
self.client.logout()
self.client.force_authenticate(user=regular_user)
task1 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_one.pdf",
owner=self.user,
)
task2 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_two.pdf",
)
task3 = PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="task_three.pdf",
owner=regular_user,
)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]["task_id"], task3.task_id)
self.assertEqual(response.data[1]["task_id"], task2.task_id)
acknowledge_response = self.client.post(
self.ENDPOINT + "acknowledge/",
{"tasks": [task1.id, task2.id, task3.id]},
)
self.assertEqual(acknowledge_response.status_code, status.HTTP_200_OK)
self.assertEqual(acknowledge_response.data, {"result": 2})
def test_task_result_no_error(self): def test_task_result_no_error(self):
""" """
GIVEN: GIVEN:

View File

@ -607,7 +607,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_consume_file.assert_not_called() mock_consume_file.assert_not_called()
@mock.patch("documents.tasks.bulk_update_documents.si") @mock.patch("documents.tasks.bulk_update_documents.si")
@mock.patch("documents.tasks.update_document_archive_file.s") @mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
@mock.patch("celery.chord.delay") @mock.patch("celery.chord.delay")
def test_rotate(self, mock_chord, mock_update_document, mock_update_documents): def test_rotate(self, mock_chord, mock_update_document, mock_update_documents):
""" """
@ -626,7 +626,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertEqual(result, "OK") self.assertEqual(result, "OK")
@mock.patch("documents.tasks.bulk_update_documents.si") @mock.patch("documents.tasks.bulk_update_documents.si")
@mock.patch("documents.tasks.update_document_archive_file.s") @mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
@mock.patch("pikepdf.Pdf.save") @mock.patch("pikepdf.Pdf.save")
def test_rotate_with_error( def test_rotate_with_error(
self, self,
@ -654,7 +654,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_update_archive_file.assert_not_called() mock_update_archive_file.assert_not_called()
@mock.patch("documents.tasks.bulk_update_documents.si") @mock.patch("documents.tasks.bulk_update_documents.si")
@mock.patch("documents.tasks.update_document_archive_file.s") @mock.patch("documents.tasks.update_document_content_maybe_archive_file.s")
@mock.patch("celery.chord.delay") @mock.patch("celery.chord.delay")
def test_rotate_non_pdf( def test_rotate_non_pdf(
self, self,
@ -680,7 +680,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mock_chord.assert_called_once() mock_chord.assert_called_once()
self.assertEqual(result, "OK") self.assertEqual(result, "OK")
@mock.patch("documents.tasks.update_document_archive_file.delay") @mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
@mock.patch("pikepdf.Pdf.save") @mock.patch("pikepdf.Pdf.save")
def test_delete_pages(self, mock_pdf_save, mock_update_archive_file): def test_delete_pages(self, mock_pdf_save, mock_update_archive_file):
""" """
@ -705,7 +705,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.doc2.refresh_from_db() self.doc2.refresh_from_db()
self.assertEqual(self.doc2.page_count, expected_page_count) self.assertEqual(self.doc2.page_count, expected_page_count)
@mock.patch("documents.tasks.update_document_archive_file.delay") @mock.patch("documents.tasks.update_document_content_maybe_archive_file.delay")
@mock.patch("pikepdf.Pdf.save") @mock.patch("pikepdf.Pdf.save")
def test_delete_pages_with_error(self, mock_pdf_save, mock_update_archive_file): def test_delete_pages_with_error(self, mock_pdf_save, mock_update_archive_file):
""" """

View File

@ -13,7 +13,7 @@ from django.test import override_settings
from documents.file_handling import generate_filename from documents.file_handling import generate_filename
from documents.models import Document from documents.models import Document
from documents.tasks import update_document_archive_file from documents.tasks import update_document_content_maybe_archive_file
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
@ -46,7 +46,7 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf"), os.path.join(self.dirs.originals_dir, f"{doc.id:07}.pdf"),
) )
update_document_archive_file(doc.pk) update_document_content_maybe_archive_file(doc.pk)
doc = Document.objects.get(id=doc.id) doc = Document.objects.get(id=doc.id)
@ -63,7 +63,7 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
doc.save() doc.save()
shutil.copy(sample_file, doc.source_path) shutil.copy(sample_file, doc.source_path)
update_document_archive_file(doc.pk) update_document_content_maybe_archive_file(doc.pk)
doc = Document.objects.get(id=doc.id) doc = Document.objects.get(id=doc.id)
@ -94,8 +94,8 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
os.path.join(self.dirs.originals_dir, "document_01.pdf"), os.path.join(self.dirs.originals_dir, "document_01.pdf"),
) )
update_document_archive_file(doc2.pk) update_document_content_maybe_archive_file(doc2.pk)
update_document_archive_file(doc1.pk) update_document_content_maybe_archive_file(doc1.pk)
doc1 = Document.objects.get(id=doc1.id) doc1 = Document.objects.get(id=doc1.id)
doc2 = Document.objects.get(id=doc2.id) doc2 = Document.objects.get(id=doc2.id)

View File

@ -5,6 +5,7 @@ import celery
from django.test import TestCase from django.test import TestCase
from documents.data_models import ConsumableDocument from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.signals.handlers import before_task_publish_handler from documents.signals.handlers import before_task_publish_handler
@ -48,7 +49,10 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
source=DocumentSource.ConsumeFolder, source=DocumentSource.ConsumeFolder,
original_file="/consume/hello-999.pdf", original_file="/consume/hello-999.pdf",
), ),
None, DocumentMetadataOverrides(
title="Hello world",
owner_id=1,
),
), ),
# kwargs # kwargs
{}, {},
@ -65,6 +69,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
self.assertEqual(headers["id"], task.task_id) self.assertEqual(headers["id"], task.task_id)
self.assertEqual("hello-999.pdf", task.task_file_name) self.assertEqual("hello-999.pdf", task.task_file_name)
self.assertEqual("documents.tasks.consume_file", task.task_name) self.assertEqual("documents.tasks.consume_file", task.task_name)
self.assertEqual(1, task.owner_id)
self.assertEqual(celery.states.PENDING, task.status) self.assertEqual(celery.states.PENDING, task.status)
def test_task_prerun_handler(self): def test_task_prerun_handler(self):

View File

@ -1,5 +1,7 @@
import os import os
import shutil
from datetime import timedelta from datetime import timedelta
from pathlib import Path
from unittest import mock from unittest import mock
from django.conf import settings from django.conf import settings
@ -184,3 +186,75 @@ class TestEmptyTrashTask(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
tasks.empty_trash() tasks.empty_trash()
self.assertEqual(Document.global_objects.count(), 0) self.assertEqual(Document.global_objects.count(), 0)
class TestUpdateContent(DirectoriesMixin, TestCase):
def test_update_content_maybe_archive_file(self):
"""
GIVEN:
- Existing document with archive file
WHEN:
- Update content task is called
THEN:
- Document is reprocessed, content and checksum are updated
"""
sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1,
)
sample1_archive = self.dirs.archive_dir / "sample_archive.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1_archive,
)
doc = Document.objects.create(
title="test",
content="my document",
checksum="wow",
archive_checksum="wow",
filename=sample1,
mime_type="application/pdf",
archive_filename=sample1_archive,
)
tasks.update_document_content_maybe_archive_file(doc.pk)
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
self.assertNotEqual(Document.objects.get(pk=doc.pk).archive_checksum, "wow")
def test_update_content_maybe_archive_file_no_archive(self):
"""
GIVEN:
- Existing document without archive file
WHEN:
- Update content task is called
THEN:
- Document is reprocessed, content is updated
"""
sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1,
)
doc = Document.objects.create(
title="test",
content="my document",
checksum="wow",
filename=sample1,
mime_type="application/pdf",
)
tasks.update_document_content_maybe_archive_file(doc.pk)
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")

View File

@ -1705,6 +1705,7 @@ class RemoteVersionView(GenericAPIView):
class TasksViewSet(ReadOnlyModelViewSet): class TasksViewSet(ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = TasksViewSerializer serializer_class = TasksViewSerializer
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
def get_queryset(self): def get_queryset(self):
queryset = ( queryset = (
@ -1719,19 +1720,17 @@ class TasksViewSet(ReadOnlyModelViewSet):
queryset = PaperlessTask.objects.filter(task_id=task_id) queryset = PaperlessTask.objects.filter(task_id=task_id)
return queryset return queryset
@action(methods=["post"], detail=False)
class AcknowledgeTasksView(GenericAPIView): def acknowledge(self, request):
permission_classes = (IsAuthenticated,) serializer = AcknowledgeTasksViewSerializer(data=request.data)
serializer_class = AcknowledgeTasksViewSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
task_ids = serializer.validated_data.get("tasks")
tasks = serializer.validated_data.get("tasks")
try: try:
result = PaperlessTask.objects.filter(id__in=tasks).update( tasks = PaperlessTask.objects.filter(id__in=task_ids)
if request.user is not None and not request.user.is_superuser:
tasks = tasks.filter(owner=request.user) | tasks.filter(owner=None)
result = tasks.update(
acknowledged=True, acknowledged=True,
) )
return Response({"result": result}) return Response({"result": result})

View File

@ -334,7 +334,7 @@ REST_FRAMEWORK = {
"DEFAULT_VERSION": "1", "DEFAULT_VERSION": "1",
# Make sure these are ordered and that the most recent version appears # Make sure these are ordered and that the most recent version appears
# last # last
"ALLOWED_VERSIONS": ["1", "2", "3", "4", "5"], "ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6"],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
} }

View File

@ -18,7 +18,6 @@ from django.views.static import serve
from rest_framework.authtoken import views from rest_framework.authtoken import views
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from documents.views import AcknowledgeTasksView
from documents.views import BulkDownloadView from documents.views import BulkDownloadView
from documents.views import BulkEditObjectsView from documents.views import BulkEditObjectsView
from documents.views import BulkEditView from documents.views import BulkEditView
@ -132,11 +131,6 @@ urlpatterns = [
name="remoteversion", name="remoteversion",
), ),
re_path("^ui_settings/", UiSettingsView.as_view(), name="ui_settings"), re_path("^ui_settings/", UiSettingsView.as_view(), name="ui_settings"),
re_path(
"^acknowledge_tasks/",
AcknowledgeTasksView.as_view(),
name="acknowledge_tasks",
),
re_path( re_path(
"^mail_accounts/test/", "^mail_accounts/test/",
MailAccountTestView.as_view(), MailAccountTestView.as_view(),