Merge pull request #1375 from tim-vogel/add_comments

Feature: document comments
This commit is contained in:
shamoon 2022-08-25 11:48:31 -07:00 committed by GitHub
commit 2b1c8c8d9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 686 additions and 34 deletions

View File

@ -44,7 +44,7 @@ resources in the documentation:
learn about how paperless automates all tagging using machine learning.
* Paperless now comes with a :ref:`proper email consumer <usage-email>`
that's fully tested and production ready.
* Paperless creates searchable PDF/A documents from whatever you you put into
* Paperless creates searchable PDF/A documents from whatever you put into
the consumption directory. This means that you can select text in
image-only documents coming from your scanner.
* See :ref:`this note <utilities-encyption>` about GnuPG encryption in

View File

@ -17,6 +17,32 @@ describe('document-detail', () => {
req.reply({ result: 'OK' })
}).as('saveDoc')
cy.fixture('documents/1/comments.json').then((commentsJson) => {
cy.intercept(
'GET',
'http://localhost:8000/api/documents/1/comments/',
(req) => {
req.reply(commentsJson.filter((c) => c.id != 10)) // 3
}
)
cy.intercept(
'DELETE',
'http://localhost:8000/api/documents/1/comments/?id=9',
(req) => {
req.reply(commentsJson.filter((c) => c.id != 9 && c.id != 10)) // 2
}
)
cy.intercept(
'POST',
'http://localhost:8000/api/documents/1/comments/',
(req) => {
req.reply(commentsJson) // 4
}
)
})
cy.viewport(1024, 1024)
cy.visit('/documents/1/')
})
@ -39,4 +65,30 @@ describe('document-detail', () => {
cy.contains('button', 'Save').click().wait('@saveDoc').wait(2000) // navigates away after saving
cy.contains('You have unsaved changes').should('not.exist')
})
it('should show a list of comments', () => {
cy.wait(1000).get('a').contains('Comments').click().wait(1000)
cy.get('app-document-comments').find('.card').its('length').should('eq', 3)
})
it('should support comment deletion', () => {
cy.wait(1000).get('a').contains('Comments').click().wait(1000)
cy.get('app-document-comments')
.find('.card')
.first()
.find('button')
.click({ force: true })
.wait(500)
cy.get('app-document-comments').find('.card').its('length').should('eq', 2)
})
it('should support comment insertion', () => {
cy.wait(1000).get('a').contains('Comments').click().wait(1000)
cy.get('app-document-comments')
.find('form textarea')
.type('Testing new comment')
.wait(500)
cy.get('app-document-comments').find('form button').click().wait(1500)
cy.get('app-document-comments').find('.card').its('length').should('eq', 4)
})
})

View File

@ -0,0 +1,46 @@
[
{
"id": 10,
"comment": "Testing new comment",
"created": "2022-08-08T04:24:55.176008Z",
"user": {
"id": 1,
"username": "user2",
"firstname": "",
"lastname": ""
}
},
{
"id": 9,
"comment": "Testing one more time",
"created": "2022-02-18T04:24:55.176008Z",
"user": {
"id": 2,
"username": "user1",
"firstname": "",
"lastname": ""
}
},
{
"id": 8,
"comment": "Another comment",
"created": "2021-11-08T04:24:47.925042Z",
"user": {
"id": 2,
"username": "user33",
"firstname": "",
"lastname": ""
}
},
{
"id": 7,
"comment": "Cupcake ipsum dolor sit amet cheesecake candy cookie tiramisu. Donut chocolate chupa chups macaroon brownie halvah pie cheesecake gummies. Sweet chocolate bar candy donut gummi bears bear claw liquorice bonbon shortbread.\n\nDonut chocolate bar candy wafer wafer tiramisu. Gummies chocolate cake muffin toffee carrot cake macaroon. Toffee toffee jelly beans danish lollipop cake.",
"created": "2021-02-08T02:37:49.724132Z",
"user": {
"id": 3,
"username": "admin",
"firstname": "",
"lastname": ""
}
}
]

View File

@ -395,7 +395,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">150</context>
<context context-type="linenumber">158</context>
</context-group>
</trans-unit>
<trans-unit id="6988090220128974198" datatype="html">
@ -701,7 +701,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">157</context>
<context context-type="linenumber">165</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
@ -816,7 +816,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">178</context>
<context context-type="linenumber">184</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
@ -824,7 +824,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">189</context>
<context context-type="linenumber">197</context>
</context-group>
</trans-unit>
<trans-unit id="6457471243969293847" datatype="html">
@ -1304,6 +1304,41 @@
<context context-type="linenumber">1</context>
</context-group>
</trans-unit>
<trans-unit id="2122666445936087317" datatype="html">
<source>Enter comment</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-comments/document-comments.component.html</context>
<context context-type="linenumber">4</context>
</context-group>
</trans-unit>
<trans-unit id="4025397324401332794" datatype="html">
<source> Please enter a comment. </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-comments/document-comments.component.html</context>
<context context-type="linenumber">5,7</context>
</context-group>
</trans-unit>
<trans-unit id="2337485514607640701" datatype="html">
<source>Add comment</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-comments/document-comments.component.html</context>
<context context-type="linenumber">10</context>
</context-group>
</trans-unit>
<trans-unit id="5438997040668245251" datatype="html">
<source>Error saving comment: <x id="PH" equiv-text="e.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-comments/document-comments.component.ts</context>
<context context-type="linenumber">57</context>
</context-group>
</trans-unit>
<trans-unit id="7593210124183303626" datatype="html">
<source>Error deleting comment: <x id="PH" equiv-text="e.toString()"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-comments/document-comments.component.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="1407560924967345762" datatype="html">
<source>Page</source>
<context-group purpose="location">
@ -1370,7 +1405,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">175</context>
<context context-type="linenumber">183</context>
</context-group>
</trans-unit>
<trans-unit id="3099741642167775297" datatype="html">
@ -1634,21 +1669,32 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">196</context>
<context context-type="linenumber">202</context>
</context-group>
</trans-unit>
<trans-unit id="3807699453257291879" datatype="html">
<source>Comments</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">173</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">128</context>
</context-group>
</trans-unit>
<trans-unit id="3823219296477075982" datatype="html">
<source>Discard</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">176</context>
<context context-type="linenumber">182</context>
</context-group>
</trans-unit>
<trans-unit id="5129524307369213584" datatype="html">
<source>Save &amp; next</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">177</context>
<context context-type="linenumber">183</context>
</context-group>
</trans-unit>
<trans-unit id="9021887951960049161" datatype="html">
@ -1832,7 +1878,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">174</context>
<context context-type="linenumber">182</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
@ -2310,14 +2356,14 @@
<source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">180</context>
<context context-type="linenumber">176</context>
</context-group>
</trans-unit>
<trans-unit id="6837554170707123455" datatype="html">
<source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">210</context>
<context context-type="linenumber">206</context>
</context-group>
</trans-unit>
<trans-unit id="6849725902312323996" datatype="html">
@ -2454,7 +2500,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">169</context>
<context context-type="linenumber">177</context>
</context-group>
</trans-unit>
<trans-unit id="4104807402967139762" datatype="html">
@ -2465,7 +2511,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">165</context>
<context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="6965614903949668392" datatype="html">
@ -2840,123 +2886,130 @@
<context context-type="linenumber">124</context>
</context-group>
</trans-unit>
<trans-unit id="4666858503087488647" datatype="html">
<source>Enable comments</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">132</context>
</context-group>
</trans-unit>
<trans-unit id="5851560788527570644" datatype="html">
<source>Notifications</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">132</context>
<context context-type="linenumber">140</context>
</context-group>
</trans-unit>
<trans-unit id="8545554728558600606" datatype="html">
<source>Document processing</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">135</context>
<context context-type="linenumber">143</context>
</context-group>
</trans-unit>
<trans-unit id="3656786776644872398" datatype="html">
<source>Show notifications when new documents are detected</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">139</context>
<context context-type="linenumber">147</context>
</context-group>
</trans-unit>
<trans-unit id="6057053428592387613" datatype="html">
<source>Show notifications when document processing completes successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">140</context>
<context context-type="linenumber">148</context>
</context-group>
</trans-unit>
<trans-unit id="370315664367425513" datatype="html">
<source>Show notifications when document processing fails</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">141</context>
<context context-type="linenumber">149</context>
</context-group>
</trans-unit>
<trans-unit id="6838309441164918531" datatype="html">
<source>Suppress notifications on dashboard</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">142</context>
<context context-type="linenumber">150</context>
</context-group>
</trans-unit>
<trans-unit id="2741919327232918179" datatype="html">
<source>This will suppress all messages about document processing status on the dashboard.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">142</context>
<context context-type="linenumber">150</context>
</context-group>
</trans-unit>
<trans-unit id="6925788033494878061" datatype="html">
<source>Appears on</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">162</context>
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="7877440816920439876" datatype="html">
<source>No saved views defined.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
<context context-type="linenumber">179</context>
<context context-type="linenumber">187</context>
</context-group>
</trans-unit>
<trans-unit id="5610279464668232148" datatype="html">
<source>Saved view &quot;<x id="PH" equiv-text="savedView.name"/>&quot; deleted.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">174</context>
<context context-type="linenumber">176</context>
</context-group>
</trans-unit>
<trans-unit id="3891152409365583719" datatype="html">
<source>Settings saved</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">247</context>
<context context-type="linenumber">253</context>
</context-group>
</trans-unit>
<trans-unit id="7217000812750597833" datatype="html">
<source>Settings were saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">248</context>
<context context-type="linenumber">254</context>
</context-group>
</trans-unit>
<trans-unit id="525012668859298131" datatype="html">
<source>Settings were saved successfully. Reload is required to apply some changes.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">252</context>
<context context-type="linenumber">258</context>
</context-group>
</trans-unit>
<trans-unit id="8491974984518503778" datatype="html">
<source>Reload now</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">253</context>
<context context-type="linenumber">259</context>
</context-group>
</trans-unit>
<trans-unit id="3011185103048412841" datatype="html">
<source>An error occurred while saving settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">263</context>
<context context-type="linenumber">269</context>
</context-group>
</trans-unit>
<trans-unit id="6839066544204061364" datatype="html">
<source>Use system language</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">271</context>
<context context-type="linenumber">277</context>
</context-group>
</trans-unit>
<trans-unit id="7729897675462249787" datatype="html">
<source>Use date format of display language</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">278</context>
<context context-type="linenumber">284</context>
</context-group>
</trans-unit>
<trans-unit id="8488620293789898901" datatype="html">
@ -2965,7 +3018,7 @@
)"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context>
<context context-type="linenumber">298,300</context>
<context context-type="linenumber">304,306</context>
</context-group>
</trans-unit>
<trans-unit id="5101757640976222639" datatype="html">
@ -3211,7 +3264,7 @@
<source>Warning: You have unsaved changes to your document(s).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/guards/dirty-doc.guard.ts</context>
<context context-type="linenumber">18</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="159901853873315050" datatype="html">

View File

@ -67,6 +67,7 @@ import { ApiVersionInterceptor } from './interceptors/api-version.interceptor'
import { ColorSliderModule } from 'ngx-color/slider'
import { ColorComponent } from './components/common/input/color/color.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DocumentCommentsComponent } from './components/document-comments/document-comments.component'
import { DirtyDocGuard } from './guards/dirty-doc.guard'
import localeBe from '@angular/common/locales/be'
@ -173,6 +174,7 @@ function initializeApp(settings: SettingsService) {
DateComponent,
ColorComponent,
DocumentAsnComponent,
DocumentCommentsComponent,
TasksComponent,
],
imports: [

View File

@ -0,0 +1,27 @@
<div *ngIf="comments">
<form [formGroup]="commentForm" class="needs-validation mt-3" novalidate>
<div class="form-group">
<textarea class="form-control form-control-sm" [class.is-invalid]="newCommentError" rows="3" formControlName="newComment" placeholder="Enter comment" i18n-placeholder required></textarea>
<div class="invalid-feedback" i18n>
Please enter a comment.
</div>
</div>
<div class="form-group mt-2 d-flex justify-content-end">
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive" (click)="addComment()" i18n>Add comment</button>
</div>
</form>
<hr>
<div *ngFor="let comment of comments" class="card border mb-3">
<div class="card-body text-dark">
<p class="card-text">{{comment.comment}}</p>
</div>
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
<span>{{displayName(comment)}} - {{ comment.created | customDate}}</span>
<button type="button" class="btn btn-link btn-sm p-0 fade" (click)="deleteComment(comment.id)">
<svg width="13" height="13" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,9 @@
.card-body {
max-height: 12rem;
overflow: scroll;
white-space: pre-wrap;
}
.card:hover .fade {
opacity: 1;
}

View File

@ -0,0 +1,90 @@
import { Component, Input, OnInit } from '@angular/core'
import { DocumentCommentsService } from 'src/app/services/rest/document-comments.service'
import { PaperlessDocumentComment } from 'src/app/data/paperless-document-comment'
import { FormControl, FormGroup } from '@angular/forms'
import { first } from 'rxjs/operators'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'app-document-comments',
templateUrl: './document-comments.component.html',
styleUrls: ['./document-comments.component.scss'],
})
export class DocumentCommentsComponent implements OnInit {
commentForm: FormGroup = new FormGroup({
newComment: new FormControl(''),
})
networkActive = false
comments: PaperlessDocumentComment[] = []
newCommentError: boolean = false
@Input()
documentId: number
constructor(
private commentsService: DocumentCommentsService,
private toastService: ToastService
) {}
ngOnInit(): void {
this.commentsService
.getComments(this.documentId)
.pipe(first())
.subscribe((comments) => (this.comments = comments))
}
addComment() {
const comment: string = this.commentForm
.get('newComment')
.value.toString()
.trim()
if (comment.length == 0) {
this.newCommentError = true
return
}
this.newCommentError = false
this.networkActive = true
this.commentsService.addComment(this.documentId, comment).subscribe({
next: (result) => {
this.comments = result
this.commentForm.get('newComment').reset()
this.networkActive = false
},
error: (e) => {
this.networkActive = false
this.toastService.showError(
$localize`Error saving comment: ${e.toString()}`
)
},
})
}
deleteComment(commentId: number) {
this.commentsService.deleteComment(this.documentId, commentId).subscribe({
next: (result) => {
this.comments = result
this.networkActive = false
},
error: (e) => {
this.networkActive = false
this.toastService.showError(
$localize`Error deleting comment: ${e.toString()}`
)
},
})
}
displayName(comment: PaperlessDocumentComment): string {
if (!comment.user) return ''
let nameComponents = []
if (comment.user.firstname) nameComponents.unshift(comment.user.firstname)
if (comment.user.lastname) nameComponents.unshift(comment.user.lastname)
if (comment.user.username) {
if (nameComponents.length > 0)
nameComponents.push(`(${comment.user.username})`)
else nameComponents.push(comment.user.username)
}
return nameComponents.join(' ')
}
}

View File

@ -170,6 +170,12 @@
</div>
</ng-template>
</li>
<li [ngbNavItem]="5" *ngIf="commentsEnabled">
<a ngbNavLink i18n>Comments</a>
<ng-template ngbNavContent>
<app-document-comments [documentId]="documentId"></app-document-comments>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div>

View File

@ -551,4 +551,8 @@ export class DocumentDetailComponent
this.password = (event.target as HTMLInputElement).value
}
}
get commentsEnabled(): boolean {
return this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED)
}
}

View File

@ -125,6 +125,14 @@
</div>
</div>
<h4 class="mt-4" i18n>Comments</h4>
<div class="row mb-3">
<div class="offset-md-3 col">
<app-input-check i18n-title title="Enable comments" formControlName="commentsEnabled"></app-input-check>
</div>
</div>
</ng-template>
</li>

View File

@ -44,6 +44,7 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent {
notificationsConsumerSuccess: new FormControl(null),
notificationsConsumerFailed: new FormControl(null),
notificationsConsumerSuppressOnDashboard: new FormControl(null),
commentsEnabled: new FormControl(null),
})
savedViews: PaperlessSavedView[]
@ -116,6 +117,7 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent {
notificationsConsumerSuppressOnDashboard: this.settings.get(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
),
commentsEnabled: this.settings.get(SETTINGS_KEYS.COMMENTS_ENABLED),
}
for (let view of this.savedViews) {
@ -234,6 +236,10 @@ export class SettingsComponent implements OnInit, OnDestroy, DirtyComponent {
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
)
this.settings.set(
SETTINGS_KEYS.COMMENTS_ENABLED,
this.settingsForm.value.commentsEnabled
)
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
this.settings
.storeSettings()

View File

@ -0,0 +1,8 @@
import { ObjectWithId } from './object-with-id'
import { User } from './user'
export interface PaperlessDocumentComment extends ObjectWithId {
created?: Date
comment?: string
user?: User
}

View File

@ -36,6 +36,7 @@ export const SETTINGS_KEYS = {
'general-settings:notifications:consumer-failed',
NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD:
'general-settings:notifications:consumer-suppress-on-dashboard',
COMMENTS_ENABLED: 'general-settings:comments-enabled',
}
export const SETTINGS: PaperlessUiSetting[] = [
@ -114,4 +115,9 @@ export const SETTINGS: PaperlessUiSetting[] = [
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.COMMENTS_ENABLED,
type: 'boolean',
default: true,
},
]

View File

@ -0,0 +1,7 @@
import { ObjectWithId } from './object-with-id'
export interface User extends ObjectWithId {
username: string
firstname: string
lastname: string
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpParams } from '@angular/common/http'
import { PaperlessDocumentComment } from 'src/app/data/paperless-document-comment'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class DocumentCommentsService extends AbstractPaperlessService<PaperlessDocumentComment> {
constructor(http: HttpClient) {
super(http, 'documents')
}
getComments(documentId: number): Observable<PaperlessDocumentComment[]> {
return this.http.get<PaperlessDocumentComment[]>(
this.getResourceUrl(documentId, 'comments')
)
}
addComment(id: number, comment): Observable<PaperlessDocumentComment[]> {
return this.http.post<PaperlessDocumentComment[]>(
this.getResourceUrl(id, 'comments'),
{ comment: comment }
)
}
deleteComment(
documentId: number,
commentId: number
): Observable<PaperlessDocumentComment[]> {
return this.http.delete<PaperlessDocumentComment[]>(
this.getResourceUrl(documentId, 'comments'),
{ params: new HttpParams({ fromString: `id=${commentId}` }) }
)
}
}

View File

@ -12,6 +12,7 @@ from django.core import serializers
from django.core.management.base import BaseCommand
from django.core.management.base import CommandError
from django.db import transaction
from documents.models import Comment
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
@ -126,6 +127,10 @@ class Command(BaseCommand):
serializers.serialize("json", DocumentType.objects.all()),
)
manifest += json.loads(
serializers.serialize("json", Comment.objects.all()),
)
documents = Document.objects.order_by("id")
document_map = {d.pk: d for d in documents}
document_manifest = json.loads(serializers.serialize("json", documents))

View File

@ -0,0 +1,28 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("documents", "1022_paperlesstask"),
]
operations = [
migrations.CreateModel(
name="Comment",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("comment", models.TextField()),
("created", models.DateTimeField(auto_now_add=True)),
("document_id", models.PositiveIntegerField()),
("user_id", models.PositiveIntegerField()),
],
)
]

View File

@ -0,0 +1,13 @@
# Generated by Django 4.0.6 on 2022-08-24 13:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("documents", "1023_add_comments"),
("documents", "1023_document_original_filename"),
]
operations = []

View File

@ -537,3 +537,43 @@ class PaperlessTask(models.Model):
blank=True,
)
acknowledged = models.BooleanField(default=False)
class Comment(models.Model):
comment = models.TextField(
_("content"),
blank=True,
help_text=_("Comment for the document"),
)
created = models.DateTimeField(
_("created"),
default=timezone.now,
db_index=True,
)
document = models.ForeignKey(
Document,
blank=True,
null=True,
related_name="documents",
on_delete=models.CASCADE,
verbose_name=_("document"),
)
user = models.ForeignKey(
User,
blank=True,
null=True,
related_name="users",
on_delete=models.SET_NULL,
verbose_name=_("user"),
)
class Meta:
ordering = ("created",)
verbose_name = _("comment")
verbose_name_plural = _("comments")
def __str__(self):
return self.content

View File

@ -32,6 +32,7 @@ from documents.models import SavedView
from documents.models import StoragePath
from documents.models import Tag
from documents.models import UiSettings
from documents.models import Comment
from documents.models import StoragePath
from documents.tests.utils import DirectoriesMixin
from paperless import version
@ -1357,6 +1358,133 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
1,
)
def test_get_existing_comments(self):
"""
GIVEN:
- A document with a single comment
WHEN:
- API reuqest for document comments is made
THEN:
- The associated comment is returned
"""
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document which will have comments!",
)
comment = Comment.objects.create(
comment="This is a comment.",
document=doc,
user=self.user,
)
response = self.client.get(
f"/api/documents/{doc.pk}/comments/",
format="json",
)
self.assertEqual(response.status_code, 200)
resp_data = response.json()
self.assertEqual(len(resp_data), 1)
resp_data = resp_data[0]
del resp_data["created"]
self.assertDictEqual(
resp_data,
{
"id": comment.id,
"comment": comment.comment,
"user": {
"id": comment.user.id,
"username": comment.user.username,
"firstname": comment.user.first_name,
"lastname": comment.user.last_name,
},
},
)
def test_create_comment(self):
"""
GIVEN:
- Existing document
WHEN:
- API request is made to add a comment
THEN:
- Comment is created and associated with document
"""
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document which will have comments added",
)
resp = self.client.post(
f"/api/documents/{doc.pk}/comments/",
data={"comment": "this is a posted comment"},
)
self.assertEqual(resp.status_code, 200)
response = self.client.get(
f"/api/documents/{doc.pk}/comments/",
format="json",
)
self.assertEqual(response.status_code, 200)
resp_data = response.json()
self.assertEqual(len(resp_data), 1)
resp_data = resp_data[0]
self.assertEqual(resp_data["comment"], "this is a posted comment")
def test_delete_comment(self):
"""
GIVEN:
- Existing document
WHEN:
- API request is made to add a comment
THEN:
- Comment is created and associated with document
"""
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document which will have comments!",
)
comment = Comment.objects.create(
comment="This is a comment.",
document=doc,
user=self.user,
)
response = self.client.delete(
f"/api/documents/{doc.pk}/comments/?id={comment.pk}",
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(Comment.objects.all()), 0)
def test_get_comments_no_doc(self):
"""
GIVEN:
- A request to get comments from a non-existent document
WHEN:
- API request for document comments is made
THEN:
- HTTP 404 is returned
"""
response = self.client.get(
"/api/documents/500/comments/",
format="json",
)
self.assertEqual(response.status_code, 404)
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
def setUp(self):

View File

@ -10,10 +10,12 @@ from django.core.management import call_command
from django.test import override_settings
from django.test import TestCase
from documents.management.commands import document_exporter
from documents.models import Comment
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.models import Tag
from documents.models import User
from documents.sanity_checker import check_sanity
from documents.settings import EXPORTER_FILE_NAME
from documents.tests.utils import DirectoriesMixin
@ -25,6 +27,8 @@ class TestExportImport(DirectoriesMixin, TestCase):
self.target = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.target)
self.user = User.objects.create(username="temp_admin")
self.d1 = Document.objects.create(
content="Content",
checksum="42995833e01aea9b3edee44bbfdd7ce1",
@ -57,6 +61,12 @@ class TestExportImport(DirectoriesMixin, TestCase):
storage_type=Document.STORAGE_TYPE_GPG,
)
self.comment = Comment.objects.create(
comment="This is a comment. amaze.",
document=self.d1,
user=self.user,
)
self.t1 = Tag.objects.create(name="t")
self.dt1 = DocumentType.objects.create(name="dt")
self.c1 = Correspondent.objects.create(name="c")
@ -110,7 +120,7 @@ class TestExportImport(DirectoriesMixin, TestCase):
manifest = self._do_export(use_filename_format=use_filename_format)
self.assertEqual(len(manifest), 8)
self.assertEqual(len(manifest), 10)
self.assertEqual(
len(list(filter(lambda e: e["model"] == "documents.document", manifest))),
4,
@ -171,6 +181,11 @@ class TestExportImport(DirectoriesMixin, TestCase):
checksum = hashlib.md5(f.read()).hexdigest()
self.assertEqual(checksum, element["fields"]["archive_checksum"])
elif element["model"] == "documents.comment":
self.assertEqual(element["fields"]["comment"], self.comment.comment)
self.assertEqual(element["fields"]["document"], self.d1.id)
self.assertEqual(element["fields"]["user"], self.user.id)
with paperless_environment() as dirs:
self.assertEqual(Document.objects.count(), 4)
Document.objects.all().delete()

View File

@ -64,6 +64,7 @@ from .matching import match_correspondents
from .matching import match_document_types
from .matching import match_storage_paths
from .matching import match_tags
from .models import Comment
from .models import Correspondent
from .models import Document
from .models import DocumentType
@ -387,6 +388,67 @@ class DocumentViewSet(
except (FileNotFoundError, Document.DoesNotExist):
raise Http404()
def getComments(self, doc):
return [
{
"id": c.id,
"comment": c.comment,
"created": c.created,
"user": {
"id": c.user.id,
"username": c.user.username,
"firstname": c.user.first_name,
"lastname": c.user.last_name,
},
}
for c in Comment.objects.filter(document=doc).order_by("-created")
]
@action(methods=["get", "post", "delete"], detail=True)
def comments(self, request, pk=None):
try:
doc = Document.objects.get(pk=pk)
except Document.DoesNotExist:
raise Http404()
currentUser = request.user
if request.method == "GET":
try:
return Response(self.getComments(doc))
except Exception as e:
logger.warning(f"An error occurred retrieving comments: {str(e)}")
return Response(
{"error": "Error retreiving comments, check logs for more detail."},
)
elif request.method == "POST":
try:
c = Comment.objects.create(
document=doc,
comment=request.data["comment"],
user=currentUser,
)
c.save()
return Response(self.getComments(doc))
except Exception as e:
logger.warning(f"An error occurred saving comment: {str(e)}")
return Response(
{
"error": "Error saving comment, check logs for more detail.",
},
)
elif request.method == "DELETE":
comment = Comment.objects.get(id=int(request.GET.get("id")))
comment.delete()
return Response(self.getComments(doc))
return Response(
{
"error": "error",
},
)
class SearchResultSerializer(DocumentSerializer):
def to_representation(self, instance):